From 4372ab5be175215f09709e5e08b1b0b4e20c3ced Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Tue, 21 Apr 2026 18:27:46 +0800 Subject: [PATCH] 1 --- ...OGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md | 52 +- ...HAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md | 454 +++- ...CTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md | 13 +- ...CTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md | 106 + ...CTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md | 106 + ...CTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md | 150 ++ ...CTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md | 92 + ...CTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md | 92 + ...CTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md | 137 ++ ...END_MIGRATION_EXECUTION_PLAN_2026-04-21.md | 19 + docs/technical/README.md | 21 +- ...HAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md | 999 +++++++++ ..._REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md | 107 + ...EFACTOR_PARALLEL_BATCH_AUDIT_2026-04-21.md | 132 ++ ...HAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md | 191 ++ ...CTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md | 101 + ...CTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md | 108 + ...CTOR_WORK_PACKAGE_C_PROGRESS_2026-04-21.md | 115 + ...CTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md | 130 ++ ...CTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md | 112 + ...CTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md | 121 ++ ...CTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md | 96 + ...CTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md | 90 + .../shared/src/contracts/customWorldAgent.ts | 548 +---- .../src/contracts/customWorldAgentActions.ts | 14 + .../src/contracts/customWorldAgentAnchors.ts | 16 + .../src/contracts/customWorldAgentDraft.ts | 29 + .../src/contracts/customWorldAgentSession.ts | 20 + .../src/contracts/customWorldResultPreview.ts | 12 + .../src/contracts/customWorldWorkSummary.ts | 11 + .../shared/src/contracts/rpgAgentActions.ts | 127 +- .../shared/src/contracts/rpgAgentAnchors.ts | 74 +- .../shared/src/contracts/rpgAgentDraft.ts | 251 +++ .../shared/src/contracts/rpgAgentSession.ts | 149 +- .../shared/src/contracts/rpgContracts.test.ts | 146 ++ .../src/contracts/rpgCreationFixtures.ts | 714 ++++++ .../src/contracts/rpgCreationPreview.ts | 40 +- .../src/contracts/rpgCreationWorkSummary.ts | 44 +- .../shared/src/contracts/rpgRuntimeChat.ts | 184 ++ .../src/contracts/rpgRuntimeContracts.test.ts | 51 + .../src/contracts/rpgRuntimeQuestAssist.ts | 83 + .../src/contracts/rpgRuntimeStoryAction.ts | 136 ++ .../src/contracts/rpgRuntimeStoryState.ts | 146 ++ packages/shared/src/contracts/story.ts | 520 ----- packages/shared/src/index.ts | 8 +- server-node/src/app.test.ts | 445 +++- server-node/src/app.ts | 91 +- server-node/src/auth/refreshSessionCookie.ts | 23 +- server-node/src/context.ts | 16 + .../src/modules/ai/chatOrchestrator.ts | 2 +- .../src/modules/ai/orchestrator.test.ts | 2 +- .../modules/combat/combatResolutionService.ts | 8 +- .../runtime-profile/buildAttributeSchema.ts | 365 ++++ .../runtime-profile/buildCompiledProfile.ts | 410 ++++ .../runtime-profile/creatorIntentBridge.ts | 82 + .../custom-world/runtime-profile/index.ts | 13 + .../runtime-profile/normalizeCamp.ts | 178 ++ .../runtime-profile/normalizeLandmark.ts | 151 ++ .../runtime-profile/normalizeRole.ts | 541 +++++ .../runtime-profile/normalizeSceneChapter.ts | 123 ++ .../runtime-profile/normalizeShared.ts | 248 +++ .../runtime-profile/runtimeProfileCompiler.ts | 13 + .../modules/custom-world/runtimeProfile.ts | 1890 +--------------- .../inventory/inventoryStoryActionService.ts | 6 +- .../npcInventoryStoryActionService.ts | 6 +- .../src/modules/npc/npcInteractionService.ts | 200 +- .../quest/questRuntimeSignalService.ts | 6 +- .../modules/quest/questStoryActionService.ts | 6 +- .../src/modules/quest/runtimeQuestModule.ts | 2 +- .../RpgRuntimeOptionCompiler.ts | 14 + .../RpgRuntimeSessionDomain.test.ts} | 6 +- .../RpgRuntimeSessionDomain.ts} | 96 +- .../RpgRuntimeSessionLoader.ts | 13 + .../RpgRuntimeSessionPrimitives.ts | 29 + .../RpgRuntimeSnapshotSync.ts | 13 + .../RpgRuntimeStoryActionDomain.ts} | 53 +- .../RpgRuntimeStoryActionService.ts | 10 + .../RpgRuntimeStoryPresentationCompiler.ts | 8 + .../RpgRuntimeStoryStateService.ts | 8 + .../src/modules/rpg-runtime-story/index.ts | 35 + .../modules/runtime-item/runtimeItemModule.ts | 2 +- .../treasureStoryActionService.ts | 6 +- server-node/src/observability.test.ts | 4 +- server-node/src/prompts/chatPromptBuilders.ts | 2 +- .../repositories/RpgAgentSessionRepository.ts | 100 + .../repositories/RpgWorldProfileRepository.ts | 433 ++++ .../rpg-entry/RpgEntryRepositories.test.ts | 241 +++ .../rpg-entry/RpgSaveArchiveRepository.ts | 36 + .../rpg-entry/RpgWorldLibraryRepository.ts | 92 + .../src/repositories/rpg-entry/index.ts | 8 + .../rpg-profile/RpgBrowseHistoryRepository.ts | 42 + .../RpgProfileDashboardRepository.ts | 49 + .../RpgProfileRepositories.test.ts | 154 ++ .../src/repositories/rpg-profile/index.ts | 8 + .../RpgRuntimeSnapshotRepository.test.ts | 126 ++ .../RpgRuntimeSnapshotRepository.ts | 35 + .../src/repositories/rpg-runtime/index.ts | 4 + .../repositories/rpgWorldRepositoryShared.ts | 116 + .../src/repositories/runtimeRepository.ts | 499 +---- server-node/src/routes/customWorldAgent.ts | 20 + server-node/src/routes/rpg-entry/index.ts | 11 + .../routes/rpg-entry/rpgEntrySaveRoutes.ts | 151 ++ .../routes/rpg-entry/rpgWorldLibraryRoutes.ts | 338 +++ server-node/src/routes/rpg-profile/index.ts | 4 + .../routes/rpg-profile/rpgProfileRoutes.ts | 214 ++ server-node/src/routes/rpg-runtime/index.ts | 8 + .../rpg-runtime/rpgRuntimeAiAssistRoutes.ts | 370 ++++ .../rpgRuntimeStoryRoutes.test.ts} | 239 +- .../rpg-runtime/rpgRuntimeStoryRoutes.ts} | 18 +- .../src/routes/rpgRouteBoundaries.test.ts | 524 +++++ .../src/routes/rpgWorldLibraryRoutes.ts | 13 - server-node/src/routes/rpgWorldWorksRoutes.ts | 13 - server-node/src/routes/runtimeRoutes.ts | 843 -------- .../syncCustomWorldSavedProfileAssets.ts | 14 +- server-node/src/server.ts | 44 +- .../RpgWorldPreviewCompiler.fixture.test.ts | 80 + .../services/RpgWorldPreviewCompiler.test.ts | 269 +++ .../src/services/RpgWorldPreviewCompiler.ts | 61 +- .../src/services/RpgWorldWorkCoverResolver.ts | 46 + ...gWorldWorkSummaryAssembler.fixture.test.ts | 96 + .../services/RpgWorldWorkSummaryAssembler.ts | 301 +++ .../services/RpgWorldWorkSummaryService.ts | 45 +- server-node/src/services/chatService.ts | 2 +- .../draftFoundationExecutor.ts | 145 ++ .../executorShared.ts | 108 + .../expandLongTailExecutor.ts | 116 + .../generateCharactersExecutor.ts | 110 + .../generateLandmarksExecutor.ts | 110 + .../generateRoleAssetsExecutor.ts | 82 + .../generateSceneAssetsExecutor.ts | 88 + .../helpers.ts | 58 + .../customWorldAgentActionExecutors/index.ts | 105 + .../publishWorldExecutor.ts | 166 ++ .../revertCheckpointExecutor.ts | 95 + .../syncResultProfileExecutor.ts | 87 + .../syncRoleAssetsExecutor.ts | 97 + .../syncSceneAssetsExecutor.ts | 88 + .../customWorldAgentActionExecutors/types.ts | 29 + .../updateDraftCardExecutor.ts | 111 + .../customWorldAgentActionRegistry.test.ts | 260 +++ .../customWorldAgentActionRegistry.ts | 403 ++++ .../customWorldAgentAssetBridgeService.ts | 227 +- .../services/customWorldAgentDraftCompiler.ts | 103 +- ...omWorldAgentFoundationDraftService.test.ts | 324 +++ .../customWorldAgentFoundationDraftService.ts | 664 +++--- .../customWorldAgentMessageTurnService.ts | 196 ++ .../services/customWorldAgentOrchestrator.ts | 1604 ++------------ .../services/customWorldAgentPhase2.test.ts | 98 +- .../services/customWorldAgentPhase3.test.ts | 156 +- .../services/customWorldAgentPhase4.test.ts | 212 +- .../services/customWorldAgentPhase5.test.ts | 729 ++++++- .../customWorldAgentPublishGateService.ts | 141 ++ .../customWorldAgentPublishService.ts | 410 ++++ .../customWorldAgentPublishingService.ts | 256 +++ .../customWorldAgentQualityGateService.ts | 88 + .../customWorldAgentRepositoryTestHelpers.ts | 305 +++ .../customWorldAgentResultSyncService.test.ts | 118 + .../customWorldAgentResultSyncService.ts | 150 ++ .../customWorldAgentRoleAssetStateService.ts | 89 +- .../services/customWorldAgentSessionStore.ts | 815 ++----- .../customWorldAgentSnapshotBuilder.ts | 199 ++ .../customWorldAgentSuggestedActionService.ts | 82 + ...orldWorkSummaryService.integration.test.ts | 113 + .../services/customWorldWorkSummaryService.ts | 303 +-- server-node/src/services/questService.ts | 4 +- .../rpgAgentSessionCompatibility.test.ts | 158 ++ .../rpgAgentSessionCompatibility.ts | 443 ++++ .../rpgAgentSessionFactory.ts | 74 + .../rpgAgentSessionRecord.ts | 98 + .../rpgAgentSessionRepositoryAdapter.ts | 24 + .../rpgCreationPreviewProfileBuilder.ts | 349 +++ .../src/services/runtimeItemService.ts | 4 +- server-node/src/services/storyService.ts | 2 +- src/App.tsx | 8 +- src/components/AdventureEntityModal.tsx | 2 +- src/components/CharacterChatModal.tsx | 2 +- src/components/CharacterPanel.tsx | 2 +- src/components/CustomWorldEntityCatalog.tsx | 4 +- .../CustomWorldEntityEditorModal.test.tsx | 72 +- src/components/CustomWorldResultView.test.tsx | 128 +- src/components/CustomWorldResultView.tsx | 791 ------- src/components/NpcModals.tsx | 2 +- .../game-shell/PreGameSelectionFlow.tsx | 1920 ----------------- .../rpg-creation-flow/RpgCreationShell.tsx | 15 - .../game-shell/rpg-creation-flow/index.ts | 4 - .../game-shell/useSceneTransitionModel.ts | 185 -- .../RpgCreationRoleAnimationSection.tsx | 244 +++ .../RpgCreationRoleAssetStudioFooter.tsx | 53 + .../RpgCreationRoleAssetStudioModal.tsx | 21 + .../RpgCreationRoleAssetStudioModalImpl.tsx} | 607 ++---- .../RpgCreationRoleVisualSection.tsx | 184 ++ .../rpg-creation-asset-studio/index.ts | 4 + .../roleAssetStudioModel.ts | 79 + .../roleAssetStudioPublishClient.ts | 13 + .../useRoleAnimationWorkflow.ts | 99 + .../useRoleVisualCandidateWorkflow.ts | 59 + .../CustomWorldCampEditorSection.tsx | 1 + .../CustomWorldCoverEditorSection.tsx | 1 + .../CustomWorldLandmarkEditorSection.tsx | 1 + .../CustomWorldRoleEditorSection.tsx | 4 + .../CustomWorldSceneChapterEditorSection.tsx | 1 + .../CustomWorldWorldEditorSection.tsx | 1 + .../RpgCreationEntityEditorModal.tsx | 15 +- .../RpgCreationEntityEditorModalImpl.tsx | 136 ++ .../RpgCreationEntityEditorShared.tsx} | 433 +--- .../rpgCreationResultFormMapper.ts | 304 +++ .../RpgCreationAssetDebugPanel.tsx | 291 +++ .../RpgCreationResultActionBar.tsx | 97 + .../RpgCreationResultHeader.tsx | 59 + .../RpgCreationResultView.tsx | 10 +- .../RpgCreationResultViewImpl.tsx | 229 ++ .../useRpgCreationResultActions.ts | 318 +++ .../RpgEntryBrandLogo.tsx} | 16 +- .../RpgEntryCharacterSelectView.test.tsx} | 4 +- .../RpgEntryCharacterSelectView.tsx} | 65 +- .../RpgEntryCreationTypeModal.tsx} | 12 +- ...EntryFlowShell.agent.interaction.test.tsx} | 502 ++++- .../rpg-entry/RpgEntryFlowShell.tsx | 20 + .../rpg-entry/RpgEntryFlowShellImpl.tsx | 718 ++++++ .../RpgEntryHomeView.tsx} | 69 +- .../RpgEntryWorldDetailView.tsx} | 34 +- src/components/rpg-entry/index.ts | 27 + src/components/rpg-entry/rpgEntryShared.ts | 110 + src/components/rpg-entry/rpgEntryTypes.ts | 39 + .../rpgEntryWorldPresentation.ts} | 8 +- .../useRpgCreationAgentOperationPolling.ts | 109 + .../rpg-entry/useRpgCreationEnterWorld.ts | 92 + .../rpg-entry/useRpgCreationResultAutosave.ts | 392 ++++ .../useRpgCreationSessionController.ts | 570 +++++ .../rpg-entry/useRpgEntryBootstrap.ts | 346 +++ .../rpg-entry/useRpgEntryCharacterSelect.ts | 46 + .../rpg-entry/useRpgEntryLibraryDetail.ts | 444 ++++ .../rpg-entry/useRpgEntryNavigation.ts | 55 + .../rpg-entry/useRpgEntrySaveResume.ts | 28 + .../RpgAdventurePanel.npcChat.test.tsx} | 12 +- .../RpgAdventurePanel.test.tsx} | 8 +- .../RpgAdventurePanel.tsx} | 908 +++++--- .../RpgAdventurePanelOverlays.tsx} | 8 +- .../RpgRuntimePanelRouter.tsx} | 88 +- src/components/rpg-runtime-panels/index.ts | 8 + .../RpgRuntimeCanvasStage.tsx} | 48 +- .../RpgRuntimeOverlayHost.tsx} | 80 +- .../RpgRuntimeShell.tsx} | 40 +- .../RpgRuntimeStageRouter.tsx} | 135 +- src/components/rpg-runtime-shell/index.ts | 36 + .../rpgRuntimeLoaders.tsx} | 15 +- .../types.ts | 30 +- .../useRpgRuntimeOverlayState.ts} | 22 +- .../useRpgRuntimeShellViewModel.test.ts} | 14 +- .../useRpgRuntimeShellViewModel.ts} | 41 +- .../useRpgSceneTransitionModel.ts | 224 ++ .../flow/campTravelHomeScene.ts | 3 +- .../flow/storyContinueAdventure.ts | 2 +- .../flow/storyOpeningCampDialogue.ts | 3 +- .../functionCatalog/functionCatalog.test.ts | 2 +- src/data/functionCatalog/npc/npcChat.ts | 3 +- src/data/functionCatalog/npc/npcFight.ts | 3 +- src/data/functionCatalog/npc/npcGift.ts | 4 +- src/data/functionCatalog/npc/npcHelp.ts | 3 +- src/data/functionCatalog/npc/npcLeave.ts | 3 +- .../functionCatalog/npc/npcPreviewTalk.ts | 2 +- src/data/functionCatalog/npc/npcRecruit.ts | 4 +- src/data/functionCatalog/npc/npcSpar.ts | 3 +- src/data/functionCatalog/npc/npcTrade.ts | 4 +- .../characterChat.ts | 2 +- .../choiceActions.test.ts | 28 +- .../choiceActions.ts | 6 +- .../{story => rpg-runtime-story}/goalFlow.ts | 2 +- src/hooks/rpg-runtime-story/index.ts | 52 + .../inventoryActions.ts | 6 +- .../npcEncounterActions.test.ts | 7 +- .../rpg-runtime-story/npcEncounterActions.ts | 6 + .../npcInteraction.ts | 216 +- .../openingAdventure.ts | 2 +- .../progressionActions.ts | 2 +- .../rpgRuntimeStoryGateway.ts} | 55 +- .../runtimeStoryCoordinator.test.ts | 14 +- .../runtimeStoryCoordinator.ts | 7 + .../sessionActions.test.ts | 2 +- .../sessionActions.ts | 2 +- .../storyCampCompanion.test.ts | 2 +- .../storyCampCompanion.ts | 2 +- .../storyChoiceContinuation.ts | 2 +- .../storyChoiceCoordinator.test.ts | 2 +- .../storyChoiceCoordinator.ts | 2 +- .../storyChoiceRuntime.test.ts | 6 +- .../storyChoiceRuntime.ts | 6 +- .../storyContextBuilder.ts | 2 +- .../storyEncounterState.test.ts | 2 +- .../storyEncounterState.ts | 2 +- .../storyGenerationState.test.ts | 2 +- .../storyGenerationState.ts | 2 +- .../storyInteractionCoordinator.test.ts | 3 +- .../storyInteractionCoordinator.ts | 21 +- .../storyPresentation.test.ts | 2 +- .../storyPresentation.ts | 2 +- .../storyRenderingHelpers.ts | 2 +- .../storyRequestCoordinator.test.ts | 2 +- .../storyRequestCoordinator.ts | 12 +- .../storyRequestRuntime.test.ts | 2 +- .../storyRequestRuntime.ts | 2 +- .../storyResponseOptions.test.ts | 2 +- .../storyResponseOptions.ts | 2 +- .../storyRuntimeSupport.test.ts | 2 +- .../storyRuntimeSupport.ts | 2 +- .../{story => rpg-runtime-story}/uiTypes.ts | 2 +- .../useRpgRuntimeInteractionFlow.test.ts} | 8 +- .../useRpgRuntimeInteractionFlow.ts} | 26 +- .../useRpgRuntimeNpcInteraction.ts} | 26 +- .../useRpgRuntimeStory.ts} | 49 +- .../useRpgRuntimeStoryController.ts} | 17 +- .../useRpgRuntimeStoryFlow.ts} | 31 +- .../useRpgRuntimeStoryState.test.ts} | 8 +- .../useRpgRuntimeStoryState.ts} | 17 +- .../useStoryChoiceCoordinator.test.ts | 2 +- .../useStoryChoiceCoordinator.ts | 2 +- .../useStoryGoalOptionCoordinator.test.ts | 2 +- .../useStoryGoalOptionCoordinator.ts | 2 +- src/hooks/rpg-session/index.ts | 12 + src/hooks/rpg-session/rpgSessionTypes.ts | 3 + .../useRpgRuntimeSession.ts} | 38 +- .../useRpgSessionBootstrap.ts} | 40 +- .../useRpgSessionPersistence.ts} | 71 +- src/hooks/runtimeAuthGuards.test.tsx | 21 +- src/hooks/useGameFlow.customWorld.test.tsx | 4 +- src/hooks/useGameSettings.ts | 9 +- src/hooks/useTreasureFlow.ts | 4 +- src/services/aiService.ts | 355 +-- .../customWorldAgentDraftResult.test.ts | 861 -------- src/services/customWorldAgentDraftResult.ts | 591 ----- src/services/customWorldSceneActRuntime.ts | 2 +- src/services/rpg-creation/index.ts | 17 +- .../rpg-creation/rpgCreationAgentClient.ts | 229 +- .../rpg-creation/rpgCreationAssetClient.ts | 119 +- .../rpgCreationGenerationClient.test.ts | 54 + .../rpgCreationGenerationClient.ts | 68 + .../rpg-creation/rpgCreationLibraryClient.ts | 171 +- .../rpgCreationPreviewAdapter.test.ts | 121 ++ .../rpg-creation/rpgCreationPreviewAdapter.ts | 34 +- .../rpg-creation/rpgCreationRequestHelpers.ts | 41 + .../rpg-creation/rpgCreationRuntimeClient.ts | 60 + .../rpg-creation/rpgCreationWorkClient.ts | 20 +- src/services/rpg-entry/index.ts | 25 + .../rpgEntryClients.routing.test.ts} | 44 +- .../rpg-entry/rpgEntryLibraryClient.test.ts | 165 ++ .../rpg-entry/rpgEntryLibraryClient.ts | 152 ++ .../rpg-entry/rpgProfileClient.test.ts | 158 ++ src/services/rpg-entry/rpgProfileClient.ts | 187 ++ src/services/rpg-runtime/index.ts | 32 + .../rpg-runtime/rpgRuntimeChatClient.ts | 29 + src/services/rpg-runtime/rpgRuntimeRequest.ts | 66 + .../rpgRuntimeStoryClient.test.ts} | 56 +- .../rpgRuntimeStoryClient.ts} | 55 +- .../rpg-runtime/rpgSnapshotClient.test.ts | 89 + src/services/rpg-runtime/rpgSnapshotClient.ts | 60 + src/services/storageService.ts | 440 ---- src/types/core.ts | 9 +- vitest.config.ts | 7 +- 358 files changed, 30788 insertions(+), 14737 deletions(-) create mode 100644 docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md create mode 100644 docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md create mode 100644 docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md create mode 100644 docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md create mode 100644 docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md create mode 100644 docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md create mode 100644 docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md create mode 100644 docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md create mode 100644 docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PARALLEL_BATCH_AUDIT_2026-04-21.md create mode 100644 docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md create mode 100644 docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md create mode 100644 docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md create mode 100644 docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_C_PROGRESS_2026-04-21.md create mode 100644 docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md create mode 100644 docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md create mode 100644 docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md create mode 100644 docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md create mode 100644 docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md create mode 100644 packages/shared/src/contracts/customWorldAgentActions.ts create mode 100644 packages/shared/src/contracts/customWorldAgentAnchors.ts create mode 100644 packages/shared/src/contracts/customWorldAgentDraft.ts create mode 100644 packages/shared/src/contracts/customWorldAgentSession.ts create mode 100644 packages/shared/src/contracts/customWorldResultPreview.ts create mode 100644 packages/shared/src/contracts/customWorldWorkSummary.ts create mode 100644 packages/shared/src/contracts/rpgAgentDraft.ts create mode 100644 packages/shared/src/contracts/rpgContracts.test.ts create mode 100644 packages/shared/src/contracts/rpgCreationFixtures.ts create mode 100644 packages/shared/src/contracts/rpgRuntimeChat.ts create mode 100644 packages/shared/src/contracts/rpgRuntimeContracts.test.ts create mode 100644 packages/shared/src/contracts/rpgRuntimeQuestAssist.ts create mode 100644 packages/shared/src/contracts/rpgRuntimeStoryAction.ts create mode 100644 packages/shared/src/contracts/rpgRuntimeStoryState.ts delete mode 100644 packages/shared/src/contracts/story.ts create mode 100644 server-node/src/modules/custom-world/runtime-profile/buildAttributeSchema.ts create mode 100644 server-node/src/modules/custom-world/runtime-profile/buildCompiledProfile.ts create mode 100644 server-node/src/modules/custom-world/runtime-profile/creatorIntentBridge.ts create mode 100644 server-node/src/modules/custom-world/runtime-profile/index.ts create mode 100644 server-node/src/modules/custom-world/runtime-profile/normalizeCamp.ts create mode 100644 server-node/src/modules/custom-world/runtime-profile/normalizeLandmark.ts create mode 100644 server-node/src/modules/custom-world/runtime-profile/normalizeRole.ts create mode 100644 server-node/src/modules/custom-world/runtime-profile/normalizeSceneChapter.ts create mode 100644 server-node/src/modules/custom-world/runtime-profile/normalizeShared.ts create mode 100644 server-node/src/modules/custom-world/runtime-profile/runtimeProfileCompiler.ts create mode 100644 server-node/src/modules/rpg-runtime-story/RpgRuntimeOptionCompiler.ts rename server-node/src/modules/{story/runtimeSession.test.ts => rpg-runtime-story/RpgRuntimeSessionDomain.test.ts} (94%) rename server-node/src/modules/{story/runtimeSession.ts => rpg-runtime-story/RpgRuntimeSessionDomain.ts} (93%) create mode 100644 server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionLoader.ts create mode 100644 server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionPrimitives.ts create mode 100644 server-node/src/modules/rpg-runtime-story/RpgRuntimeSnapshotSync.ts rename server-node/src/modules/{story/storyActionService.ts => rpg-runtime-story/RpgRuntimeStoryActionDomain.ts} (96%) create mode 100644 server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionService.ts create mode 100644 server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryPresentationCompiler.ts create mode 100644 server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryStateService.ts create mode 100644 server-node/src/modules/rpg-runtime-story/index.ts create mode 100644 server-node/src/repositories/RpgAgentSessionRepository.ts create mode 100644 server-node/src/repositories/RpgWorldProfileRepository.ts create mode 100644 server-node/src/repositories/rpg-entry/RpgEntryRepositories.test.ts create mode 100644 server-node/src/repositories/rpg-entry/RpgSaveArchiveRepository.ts create mode 100644 server-node/src/repositories/rpg-entry/RpgWorldLibraryRepository.ts create mode 100644 server-node/src/repositories/rpg-entry/index.ts create mode 100644 server-node/src/repositories/rpg-profile/RpgBrowseHistoryRepository.ts create mode 100644 server-node/src/repositories/rpg-profile/RpgProfileDashboardRepository.ts create mode 100644 server-node/src/repositories/rpg-profile/RpgProfileRepositories.test.ts create mode 100644 server-node/src/repositories/rpg-profile/index.ts create mode 100644 server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.test.ts create mode 100644 server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.ts create mode 100644 server-node/src/repositories/rpg-runtime/index.ts create mode 100644 server-node/src/repositories/rpgWorldRepositoryShared.ts create mode 100644 server-node/src/routes/rpg-entry/index.ts create mode 100644 server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts create mode 100644 server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts create mode 100644 server-node/src/routes/rpg-profile/index.ts create mode 100644 server-node/src/routes/rpg-profile/rpgProfileRoutes.ts create mode 100644 server-node/src/routes/rpg-runtime/index.ts create mode 100644 server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts rename server-node/src/{modules/story/storyActionRoutes.test.ts => routes/rpg-runtime/rpgRuntimeStoryRoutes.test.ts} (91%) rename server-node/src/{modules/story/storyActionRoutes.ts => routes/rpg-runtime/rpgRuntimeStoryRoutes.ts} (80%) create mode 100644 server-node/src/routes/rpgRouteBoundaries.test.ts delete mode 100644 server-node/src/routes/rpgWorldLibraryRoutes.ts delete mode 100644 server-node/src/routes/rpgWorldWorksRoutes.ts delete mode 100644 server-node/src/routes/runtimeRoutes.ts create mode 100644 server-node/src/services/RpgWorldPreviewCompiler.fixture.test.ts create mode 100644 server-node/src/services/RpgWorldPreviewCompiler.test.ts create mode 100644 server-node/src/services/RpgWorldWorkCoverResolver.ts create mode 100644 server-node/src/services/RpgWorldWorkSummaryAssembler.fixture.test.ts create mode 100644 server-node/src/services/RpgWorldWorkSummaryAssembler.ts create mode 100644 server-node/src/services/customWorldAgentActionExecutors/draftFoundationExecutor.ts create mode 100644 server-node/src/services/customWorldAgentActionExecutors/executorShared.ts create mode 100644 server-node/src/services/customWorldAgentActionExecutors/expandLongTailExecutor.ts create mode 100644 server-node/src/services/customWorldAgentActionExecutors/generateCharactersExecutor.ts create mode 100644 server-node/src/services/customWorldAgentActionExecutors/generateLandmarksExecutor.ts create mode 100644 server-node/src/services/customWorldAgentActionExecutors/generateRoleAssetsExecutor.ts create mode 100644 server-node/src/services/customWorldAgentActionExecutors/generateSceneAssetsExecutor.ts create mode 100644 server-node/src/services/customWorldAgentActionExecutors/helpers.ts create mode 100644 server-node/src/services/customWorldAgentActionExecutors/index.ts create mode 100644 server-node/src/services/customWorldAgentActionExecutors/publishWorldExecutor.ts create mode 100644 server-node/src/services/customWorldAgentActionExecutors/revertCheckpointExecutor.ts create mode 100644 server-node/src/services/customWorldAgentActionExecutors/syncResultProfileExecutor.ts create mode 100644 server-node/src/services/customWorldAgentActionExecutors/syncRoleAssetsExecutor.ts create mode 100644 server-node/src/services/customWorldAgentActionExecutors/syncSceneAssetsExecutor.ts create mode 100644 server-node/src/services/customWorldAgentActionExecutors/types.ts create mode 100644 server-node/src/services/customWorldAgentActionExecutors/updateDraftCardExecutor.ts create mode 100644 server-node/src/services/customWorldAgentActionRegistry.test.ts create mode 100644 server-node/src/services/customWorldAgentActionRegistry.ts create mode 100644 server-node/src/services/customWorldAgentFoundationDraftService.test.ts create mode 100644 server-node/src/services/customWorldAgentMessageTurnService.ts create mode 100644 server-node/src/services/customWorldAgentPublishGateService.ts create mode 100644 server-node/src/services/customWorldAgentPublishService.ts create mode 100644 server-node/src/services/customWorldAgentPublishingService.ts create mode 100644 server-node/src/services/customWorldAgentQualityGateService.ts create mode 100644 server-node/src/services/customWorldAgentRepositoryTestHelpers.ts create mode 100644 server-node/src/services/customWorldAgentResultSyncService.test.ts create mode 100644 server-node/src/services/customWorldAgentResultSyncService.ts create mode 100644 server-node/src/services/customWorldAgentSnapshotBuilder.ts create mode 100644 server-node/src/services/customWorldAgentSuggestedActionService.ts create mode 100644 server-node/src/services/customWorldWorkSummaryService.integration.test.ts create mode 100644 server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.test.ts create mode 100644 server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.ts create mode 100644 server-node/src/services/rpg-agent-session-store/rpgAgentSessionFactory.ts create mode 100644 server-node/src/services/rpg-agent-session-store/rpgAgentSessionRecord.ts create mode 100644 server-node/src/services/rpg-agent-session-store/rpgAgentSessionRepositoryAdapter.ts create mode 100644 server-node/src/services/rpgCreationPreviewProfileBuilder.ts delete mode 100644 src/components/CustomWorldResultView.tsx delete mode 100644 src/components/game-shell/PreGameSelectionFlow.tsx delete mode 100644 src/components/game-shell/rpg-creation-flow/RpgCreationShell.tsx delete mode 100644 src/components/game-shell/rpg-creation-flow/index.ts delete mode 100644 src/components/game-shell/useSceneTransitionModel.ts create mode 100644 src/components/rpg-creation-asset-studio/RpgCreationRoleAnimationSection.tsx create mode 100644 src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioFooter.tsx create mode 100644 src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.tsx rename src/components/{CustomWorldRoleAssetStudioModal.tsx => rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx} (62%) create mode 100644 src/components/rpg-creation-asset-studio/RpgCreationRoleVisualSection.tsx create mode 100644 src/components/rpg-creation-asset-studio/index.ts create mode 100644 src/components/rpg-creation-asset-studio/roleAssetStudioModel.ts create mode 100644 src/components/rpg-creation-asset-studio/roleAssetStudioPublishClient.ts create mode 100644 src/components/rpg-creation-asset-studio/useRoleAnimationWorkflow.ts create mode 100644 src/components/rpg-creation-asset-studio/useRoleVisualCandidateWorkflow.ts create mode 100644 src/components/rpg-creation-editor/CustomWorldCampEditorSection.tsx create mode 100644 src/components/rpg-creation-editor/CustomWorldCoverEditorSection.tsx create mode 100644 src/components/rpg-creation-editor/CustomWorldLandmarkEditorSection.tsx create mode 100644 src/components/rpg-creation-editor/CustomWorldRoleEditorSection.tsx create mode 100644 src/components/rpg-creation-editor/CustomWorldSceneChapterEditorSection.tsx create mode 100644 src/components/rpg-creation-editor/CustomWorldWorldEditorSection.tsx create mode 100644 src/components/rpg-creation-editor/RpgCreationEntityEditorModalImpl.tsx rename src/components/{CustomWorldEntityEditorModal.tsx => rpg-creation-editor/RpgCreationEntityEditorShared.tsx} (94%) create mode 100644 src/components/rpg-creation-editor/rpgCreationResultFormMapper.ts create mode 100644 src/components/rpg-creation-result/RpgCreationAssetDebugPanel.tsx create mode 100644 src/components/rpg-creation-result/RpgCreationResultActionBar.tsx create mode 100644 src/components/rpg-creation-result/RpgCreationResultHeader.tsx create mode 100644 src/components/rpg-creation-result/RpgCreationResultViewImpl.tsx create mode 100644 src/components/rpg-creation-result/useRpgCreationResultActions.ts rename src/components/{game-shell/PlatformBrandLogo.tsx => rpg-entry/RpgEntryBrandLogo.tsx} (66%) rename src/components/{game-shell/CharacterSelectionFlow.test.tsx => rpg-entry/RpgEntryCharacterSelectView.test.tsx} (98%) rename src/components/{game-shell/CharacterSelectionFlow.tsx => rpg-entry/RpgEntryCharacterSelectView.tsx} (92%) rename src/components/{game-shell/PlatformCreationTypeModal.tsx => rpg-entry/RpgEntryCreationTypeModal.tsx} (94%) rename src/components/{game-shell/PreGameSelectionFlow.agent.interaction.test.tsx => rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx} (71%) create mode 100644 src/components/rpg-entry/RpgEntryFlowShell.tsx create mode 100644 src/components/rpg-entry/RpgEntryFlowShellImpl.tsx rename src/components/{game-shell/PlatformHomeView.tsx => rpg-entry/RpgEntryHomeView.tsx} (99%) rename src/components/{game-shell/PlatformWorldDetailView.tsx => rpg-entry/RpgEntryWorldDetailView.tsx} (96%) create mode 100644 src/components/rpg-entry/index.ts create mode 100644 src/components/rpg-entry/rpgEntryShared.ts create mode 100644 src/components/rpg-entry/rpgEntryTypes.ts rename src/components/{game-shell/platformWorldPresentation.ts => rpg-entry/rpgEntryWorldPresentation.ts} (92%) create mode 100644 src/components/rpg-entry/useRpgCreationAgentOperationPolling.ts create mode 100644 src/components/rpg-entry/useRpgCreationEnterWorld.ts create mode 100644 src/components/rpg-entry/useRpgCreationResultAutosave.ts create mode 100644 src/components/rpg-entry/useRpgCreationSessionController.ts create mode 100644 src/components/rpg-entry/useRpgEntryBootstrap.ts create mode 100644 src/components/rpg-entry/useRpgEntryCharacterSelect.ts create mode 100644 src/components/rpg-entry/useRpgEntryLibraryDetail.ts create mode 100644 src/components/rpg-entry/useRpgEntryNavigation.ts create mode 100644 src/components/rpg-entry/useRpgEntrySaveResume.ts rename src/components/{AdventurePanel.npcChat.test.tsx => rpg-runtime-panels/RpgAdventurePanel.npcChat.test.tsx} (96%) rename src/components/{AdventurePanel.test.tsx => rpg-runtime-panels/RpgAdventurePanel.test.tsx} (98%) rename src/components/{AdventurePanel.tsx => rpg-runtime-panels/RpgAdventurePanel.tsx} (60%) rename src/components/{adventure-panel/AdventurePanelOverlays.tsx => rpg-runtime-panels/RpgAdventurePanelOverlays.tsx} (99%) rename src/components/{game-shell/GameShellStoryPanels.tsx => rpg-runtime-panels/RpgRuntimePanelRouter.tsx} (93%) create mode 100644 src/components/rpg-runtime-panels/index.ts rename src/components/{game-shell/GameShellCanvasStage.tsx => rpg-runtime-shell/RpgRuntimeCanvasStage.tsx} (76%) rename src/components/{game-shell/GameShellOverlays.tsx => rpg-runtime-shell/RpgRuntimeOverlayHost.tsx} (92%) rename src/components/{game-shell/GameShellRuntime.tsx => rpg-runtime-shell/RpgRuntimeShell.tsx} (89%) rename src/components/{game-shell/GameShellMainContent.tsx => rpg-runtime-shell/RpgRuntimeStageRouter.tsx} (85%) create mode 100644 src/components/rpg-runtime-shell/index.ts rename src/components/{game-shell/GameShellLoaders.tsx => rpg-runtime-shell/rpgRuntimeLoaders.tsx} (62%) rename src/components/{game-shell => rpg-runtime-shell}/types.ts (80%) rename src/components/{game-shell/useGameShellViewModel.ts => rpg-runtime-shell/useRpgRuntimeOverlayState.ts} (79%) rename src/components/{game-shell/useGameShellRuntimeViewModel.test.ts => rpg-runtime-shell/useRpgRuntimeShellViewModel.test.ts} (95%) rename src/components/{game-shell/useGameShellRuntimeViewModel.ts => rpg-runtime-shell/useRpgRuntimeShellViewModel.ts} (87%) create mode 100644 src/components/rpg-runtime-shell/useRpgSceneTransitionModel.ts rename src/hooks/{story => rpg-runtime-story}/characterChat.ts (99%) rename src/hooks/{story => rpg-runtime-story}/choiceActions.test.ts (95%) rename src/hooks/{story => rpg-runtime-story}/choiceActions.ts (98%) rename src/hooks/{story => rpg-runtime-story}/goalFlow.ts (97%) create mode 100644 src/hooks/rpg-runtime-story/index.ts rename src/hooks/{story => rpg-runtime-story}/inventoryActions.ts (95%) rename src/hooks/{story => rpg-runtime-story}/npcEncounterActions.test.ts (99%) create mode 100644 src/hooks/rpg-runtime-story/npcEncounterActions.ts rename src/hooks/{story => rpg-runtime-story}/npcInteraction.ts (82%) rename src/hooks/{story => rpg-runtime-story}/openingAdventure.ts (99%) rename src/hooks/{story => rpg-runtime-story}/progressionActions.ts (99%) rename src/hooks/{story/runtimeStoryCoordinator.ts => rpg-runtime-story/rpgRuntimeStoryGateway.ts} (67%) rename src/hooks/{story => rpg-runtime-story}/runtimeStoryCoordinator.test.ts (96%) create mode 100644 src/hooks/rpg-runtime-story/runtimeStoryCoordinator.ts rename src/hooks/{story => rpg-runtime-story}/sessionActions.test.ts (99%) rename src/hooks/{story => rpg-runtime-story}/sessionActions.ts (99%) rename src/hooks/{story => rpg-runtime-story}/storyCampCompanion.test.ts (99%) rename src/hooks/{story => rpg-runtime-story}/storyCampCompanion.ts (99%) rename src/hooks/{story => rpg-runtime-story}/storyChoiceContinuation.ts (99%) rename src/hooks/{story => rpg-runtime-story}/storyChoiceCoordinator.test.ts (98%) rename src/hooks/{story => rpg-runtime-story}/storyChoiceCoordinator.ts (99%) rename src/hooks/{story => rpg-runtime-story}/storyChoiceRuntime.test.ts (98%) rename src/hooks/{story => rpg-runtime-story}/storyChoiceRuntime.ts (98%) rename src/hooks/{story => rpg-runtime-story}/storyContextBuilder.ts (99%) rename src/hooks/{story => rpg-runtime-story}/storyEncounterState.test.ts (98%) rename src/hooks/{story => rpg-runtime-story}/storyEncounterState.ts (98%) rename src/hooks/{story => rpg-runtime-story}/storyGenerationState.test.ts (99%) rename src/hooks/{story => rpg-runtime-story}/storyGenerationState.ts (99%) rename src/hooks/{story => rpg-runtime-story}/storyInteractionCoordinator.test.ts (98%) rename src/hooks/{story => rpg-runtime-story}/storyInteractionCoordinator.ts (84%) rename src/hooks/{story => rpg-runtime-story}/storyPresentation.test.ts (98%) rename src/hooks/{story => rpg-runtime-story}/storyPresentation.ts (98%) rename src/hooks/{story => rpg-runtime-story}/storyRenderingHelpers.ts (99%) rename src/hooks/{story => rpg-runtime-story}/storyRequestCoordinator.test.ts (98%) rename src/hooks/{story => rpg-runtime-story}/storyRequestCoordinator.ts (92%) rename src/hooks/{story => rpg-runtime-story}/storyRequestRuntime.test.ts (98%) rename src/hooks/{story => rpg-runtime-story}/storyRequestRuntime.ts (99%) rename src/hooks/{story => rpg-runtime-story}/storyResponseOptions.test.ts (99%) rename src/hooks/{story => rpg-runtime-story}/storyResponseOptions.ts (97%) rename src/hooks/{story => rpg-runtime-story}/storyRuntimeSupport.test.ts (98%) rename src/hooks/{story => rpg-runtime-story}/storyRuntimeSupport.ts (99%) rename src/hooks/{story => rpg-runtime-story}/uiTypes.ts (99%) rename src/hooks/{story/useStoryInteractionCoordinator.test.ts => rpg-runtime-story/useRpgRuntimeInteractionFlow.test.ts} (53%) rename src/hooks/{story/useStoryInteractionCoordinator.ts => rpg-runtime-story/useRpgRuntimeInteractionFlow.ts} (91%) rename src/hooks/{story/npcEncounterActions.ts => rpg-runtime-story/useRpgRuntimeNpcInteraction.ts} (98%) rename src/hooks/{useStoryGeneration.ts => rpg-runtime-story/useRpgRuntimeStory.ts} (69%) rename src/hooks/{story/useStoryRuntimeController.ts => rpg-runtime-story/useRpgRuntimeStoryController.ts} (86%) rename src/hooks/{story/useStoryFlowCoordinator.ts => rpg-runtime-story/useRpgRuntimeStoryFlow.ts} (85%) rename src/hooks/{story/useStoryGoalSessionCoordinator.test.ts => rpg-runtime-story/useRpgRuntimeStoryState.test.ts} (74%) rename src/hooks/{story/useStoryGoalSessionCoordinator.ts => rpg-runtime-story/useRpgRuntimeStoryState.ts} (84%) rename src/hooks/{story => rpg-runtime-story}/useStoryChoiceCoordinator.test.ts (88%) rename src/hooks/{story => rpg-runtime-story}/useStoryChoiceCoordinator.ts (98%) rename src/hooks/{story => rpg-runtime-story}/useStoryGoalOptionCoordinator.test.ts (90%) rename src/hooks/{story => rpg-runtime-story}/useStoryGoalOptionCoordinator.ts (96%) create mode 100644 src/hooks/rpg-session/index.ts create mode 100644 src/hooks/rpg-session/rpgSessionTypes.ts rename src/hooks/{useGameShellRuntime.ts => rpg-session/useRpgRuntimeSession.ts} (79%) rename src/hooks/{useGameFlow.ts => rpg-session/useRpgSessionBootstrap.ts} (91%) rename src/hooks/{useGamePersistence.ts => rpg-session/useRpgSessionPersistence.ts} (84%) delete mode 100644 src/services/customWorldAgentDraftResult.test.ts delete mode 100644 src/services/customWorldAgentDraftResult.ts create mode 100644 src/services/rpg-creation/rpgCreationGenerationClient.test.ts create mode 100644 src/services/rpg-creation/rpgCreationGenerationClient.ts create mode 100644 src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts create mode 100644 src/services/rpg-creation/rpgCreationRequestHelpers.ts create mode 100644 src/services/rpg-creation/rpgCreationRuntimeClient.ts create mode 100644 src/services/rpg-entry/index.ts rename src/services/{storageService.test.ts => rpg-entry/rpgEntryClients.routing.test.ts} (84%) create mode 100644 src/services/rpg-entry/rpgEntryLibraryClient.test.ts create mode 100644 src/services/rpg-entry/rpgEntryLibraryClient.ts create mode 100644 src/services/rpg-entry/rpgProfileClient.test.ts create mode 100644 src/services/rpg-entry/rpgProfileClient.ts create mode 100644 src/services/rpg-runtime/index.ts create mode 100644 src/services/rpg-runtime/rpgRuntimeChatClient.ts create mode 100644 src/services/rpg-runtime/rpgRuntimeRequest.ts rename src/services/{runtimeStoryService.test.ts => rpg-runtime/rpgRuntimeStoryClient.test.ts} (89%) rename src/services/{runtimeStoryService.ts => rpg-runtime/rpgRuntimeStoryClient.ts} (78%) create mode 100644 src/services/rpg-runtime/rpgSnapshotClient.test.ts create mode 100644 src/services/rpg-runtime/rpgSnapshotClient.ts delete mode 100644 src/services/storageService.ts diff --git a/docs/audits/engineering/FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md b/docs/audits/engineering/FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md index 66b66ef5..63b5b541 100644 --- a/docs/audits/engineering/FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md +++ b/docs/audits/engineering/FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md @@ -19,7 +19,7 @@ ## 1. 结论先行 -结合当前代码与已有边界文档,前端里仍有 6 类逻辑应该继续后移: +结合当前代码与已有边界文档,前端里仍有 7 类逻辑应该继续后移: 1. **运行时快照前置写入与本地镜像解释** 2. **鉴权 token 的浏览器本地真相** @@ -27,6 +27,7 @@ 4. **NPC 待接委托“换单”仍由前端直接触发正式生成** 5. **quest/runtime item 的双环境混合编排** 6. **浏览器侧大型 AI orchestration 与 prompt/repair/fallback 主链** +7. **NPC 招募对白之后的正式结算链路** 一句话判断: @@ -59,6 +60,20 @@ ## 3. 当前高置信度应后移逻辑 +## 3.0 本轮已完成后移 + +以下链路已在本轮或上一轮连续落地中完成后移,不再属于“仍残留在前端”的正式主链: + +1. access token 浏览器本地真相 +2. browse history 本地真相 +3. runtime story 前置 `PUT /runtime/save/snapshot` +4. NPC 待接委托 `replace / abandon / accept` +5. custom world profile 正式浏览器入口 +6. `questDirector` / `runtimeItemAiDirector` 浏览器正式编排 +7. NPC 招募正式结算 + +其中 NPC 招募已从“前端本地改 companions / roster / npcStates / storyHistory”收回到后端 runtime action。 + ## 3.1 运行时快照前置写入仍在前端 ### 代码证据 @@ -427,6 +442,41 @@ --- +## 3.8 NPC 招募对白之后的正式结算链路已完成后移 + +### 本轮前状态 + +迁移前,`src/hooks/story/npcInteraction.ts` 中的 `buildRecruitmentOutcome / executeRecruitment / startRecruitmentSequence` 仍在前端本地正式结算: + +1. 改 `npcStates` +2. 改 `companions` +3. 改 `roster` +4. 清 `currentEncounter / inBattle / sceneHostileNpcs` +5. 直接写 `storyHistory` +6. 再触发后续剧情推进 + +这与“前端只做表现,所有正式逻辑、数据都放到 Express 后端”直接冲突。 + +### 本轮后状态 + +本轮已完成: + +1. `server-node/src/modules/story/runtimeSession.ts` + - 正式承接完整 `companions` + - 正式承接 `roster` +2. `server-node/src/modules/npc/npcInteractionService.ts` + - `npc_recruit` 已支持正常入队 + - `npc_recruit` 已支持满员换队招募 +3. `src/hooks/story/npcInteraction.ts` + - 前端只保留招募对白流式展示 + - 正式招募结算改为调用后端 runtime action + +### 当前判断 + +这一项已不再属于前端残留正式逻辑。 + +--- + ## 4. 可以暂时保留在前端的部分 下面这些内容即使和上述模块同文件出现,也不属于必须后移的对象: diff --git a/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md index 2a2cc0f1..131bac39 100644 --- a/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md +++ b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md @@ -708,7 +708,7 @@ store 当前混合了: 3. 已新增 `rpgCreationAgentClient`、`rpgCreationWorkClient`、`rpgCreationLibraryClient`、`rpgCreationAssetClient`、`rpgCreationPreviewAdapter`。 4. 已新增后端 `rpgCreationAgentRoutes`、`rpgWorldWorksRoutes`、`rpgWorldLibraryRoutes`、`rpgWorldGalleryRoutes` 命名骨架。 5. 已新增 `RpgAgentOrchestrator`、`RpgAgentSessionStore`、`RpgWorldPreviewCompiler`、`RpgWorldWorkSummaryService` façade。 -6. 已新增 `rpgAgent*` 与 `rpgCreation*` 共享契约骨架。 +6. 已新增 `rpgAgent*` 与 `rpgCreation*` 共享契约骨架,并补齐此前遗漏的 `rpgAgentDraft.ts` 与 shared 根导出。 本轮刻意未做: @@ -738,6 +738,25 @@ store 当前混合了: 最好在工作包 A 的目录骨架准备好后开始。 +### 当前进展(`2026-04-21`) + +工作包 B 已完成以下落地: + +1. 已把 `PreGameSelectionFlow.tsx` 降级为兼容入口,旧路径继续导出 `PreGameSelectionFlow`、`PreGameSelectionFlowProps`、`SelectionStage`。 +2. 已把 RPG 创作平台壳层的真实实现迁入 `src/components/game-shell/rpg-creation-flow/RpgCreationShellImpl.tsx`,并把该文件收口成“hooks 组合 + stage 视图装配 + 视觉级 loading/error”的壳层。 +3. `RpgCreationShell.tsx` 已直接桥接 `RpgCreationShellImpl.tsx`,新目录开始承接真实入口。 +4. 已新增 `rpgCreationFlowTypes.ts`、`rpgCreationFlowShared.ts`,把壳层类型与共享 helper 从旧入口文件中收出独立落点。 +5. 已接入 `useRpgCreationPlatformBootstrap.ts`、`useRpgCreationSessionController.ts`、`useRpgCreationAgentOperationPolling.ts`、`useRpgCreationDetailNavigation.ts`、`useRpgCreationResultAutosave.ts`、`useRpgCreationEnterWorld.ts`。 +6. 平台侧 works / library / gallery / history / save / dashboard 拉取、session 恢复、message streaming、action 执行、operation 轮询、detail navigation、结果页自动保存、enter-world 同步已不再直接堆在壳层组件中。 +7. 已完成 `PreGameSelectionFlow.agent.interaction.test.tsx` 的 14 个交互场景回归,以及壳层相关定向 eslint、编码检查。 + +本轮刻意未做: + +1. 还没有物理删除 `PreGameSelectionFlow.tsx` 与其他旧兼容 façade,当前仍保留桥接层以避免影响并行工作包。 +2. 还没有让所有调用方统一显式改走 `RpgCreationShell` 新入口,当前允许旧路径兼容收口到新实现。 +3. 还没有把结果页 preview 数据源从前端兼容 adapter 切到服务端正式 preview contract,这部分仍属于后续工作包 G / H 与 Phase 3 范围。 +4. 还没有清理所有 legacy 兼容导出,本轮优先完成平台壳层编排拆分与主链稳定验证。 + ## 9.3 工作包 C:前端结果页与编辑器拆分 ### 目标 @@ -761,6 +780,24 @@ store 当前混合了: 依赖工作包 A 的命名规范与目录落点,和工作包 B 并行。 +### 当前进展(`2026-04-21`) + +工作包 C 已完成以下拆分落地: + +1. 已把 `CustomWorldResultView.tsx`、`CustomWorldEntityEditorModal.tsx`、`CustomWorldRoleAssetStudioModal.tsx` 的真实实现迁入 `src/components/rpg-creation-result/`、`src/components/rpg-creation-editor/`、`src/components/rpg-creation-asset-studio/`。 +2. 已把旧文件降级为兼容入口,现有调用仍可继续从旧路径导入,不影响当前主链行为。 +3. 结果页已拆出 `RpgCreationResultHeader`、`RpgCreationResultActionBar`、`RpgCreationAssetDebugPanel`、`useRpgCreationResultActions`,结果页主组件开始退化为组合壳层。 +4. 编辑器已补 `rpgCreationResultFormMapper.ts`,并把 `RpgCreationEntityEditorModalImpl.tsx` 收口成目标分发壳层;`world / cover / camp / playable / story / landmark` 已有稳定 section 入口。 +5. 编辑器当前保留 `RpgCreationEntityEditorShared.tsx` 作为阶段性 shared 实现承载体,避免在同一轮里高风险物理拆散 180KB 级表单细节;后续可在不改壳层接口的前提下继续向各 section 文件迁移。 +6. 角色资产工坊已补 `roleAssetStudioModel.ts`、`roleAssetStudioPublishClient.ts`、`useRoleVisualCandidateWorkflow.ts`、`useRoleAnimationWorkflow.ts`,并进一步拆出 `RpgCreationRoleVisualSection.tsx`、`RpgCreationRoleAnimationSection.tsx`、`RpgCreationRoleAssetStudioFooter.tsx`,当前主模态已退化为组合壳层。 +7. 旧 `CustomWorldResultView.tsx`、`CustomWorldEntityEditorModal.tsx`、`CustomWorldRoleAssetStudioModal.tsx` 兼容入口已统一桥接到 RPG 创作域 façade,而不是继续直连内部 `Impl` 文件。 + +本轮刻意未做: + +1. 还没有把 `RpgCreationEntityEditorShared.tsx` 内部的全部表单实现物理拆成独立文件,当前先以“壳层 + section 入口 + shared 实现”完成工作包 C 收口。 +2. 还没有改平台壳层 `PreGameSelectionFlow.tsx` 的任何主状态编排,仍严格遵守工作包 C 的写入边界。 +3. 还没有把结果页从 legacy preview 兼容数据源切到服务端正式 preview contract,这部分属于后续工作包 D / G / H 的协作范围。 + ## 9.4 工作包 D:前端 custom world client 收口 ### 目标 @@ -784,6 +821,24 @@ store 当前混合了: 依赖工作包 A 的命名和目录约束;可与 B、C 并行。 +### 当前进展(`2026-04-21`) + +工作包 D 第一轮已完成以下落地: + +1. 已新增 `src/services/rpg-creation/rpgCreationRuntimeClient.ts` 与 `src/services/rpg-creation/rpgCreationRequestHelpers.ts`,把 RPG 创作域的 runtime 请求重试策略、POST JSON 与 SSE 请求辅助能力收口到新目录。 +2. `rpgCreationAgentClient.ts`、`rpgCreationWorkClient.ts`、`rpgCreationLibraryClient.ts`、`rpgCreationAssetClient.ts`、`rpgCreationGenerationClient.ts` 已从 façade 透传升级为真实请求实现,不再继续把主链请求代码堆在 `aiService.ts`、`storageService.ts` 中。 +3. `generateCustomWorldProfile()` 已正式迁入 `rpgCreationGenerationClient.ts`,世界生成入口也已纳入 RPG 创作域 client 边界。 +4. `aiService.ts` 中已迁出的 Agent / works / 世界生成 / 结果页实体生成接口已退化为兼容导出;`storageService.ts` 中 works / library / gallery / publish 链路也已退化为兼容导出。 +5. `PreGameSelectionFlow.tsx` 已开始直接从 `src/services/rpg-creation/` 消费 Agent / works / library / gallery / publish 请求,不再从旧 service 入口拿主链接口。 +6. `RpgCreationEntityEditorShared.tsx` 已把场景图生成请求切到 `rpgCreationAssetClient`,结果页与编辑器相关测试也已改为优先 mock 新的 RPG 创作域 client。 +7. 已完成 `rpgCreationGenerationClient.test.ts`、`storageService.test.ts`、`CustomWorldEntityEditorModal.test.tsx`、`CustomWorldResultView.test.tsx`、`PreGameSelectionFlow.agent.interaction.test.tsx` 的定向回归,以及编码检查。 + +本轮刻意未做: + +1. 还没有物理删除 `aiService.ts`、`storageService.ts` 中的旧命名兼容导出,本轮优先保证调用迁移可平稳过渡。 +2. 还没有改平台壳层的内部流程编排与 hook 结构,这部分仍属于工作包 B。 +3. 还没有把结果页从 legacy preview 兼容数据源切到服务端正式 preview contract,这部分仍属于后续工作包 G / H 的协作范围。 + ## 9.5 工作包 E:后端 Agent 编排拆分 ### 目标 @@ -809,6 +864,58 @@ store 当前混合了: 建议在工作包 A 后开始;可与 B、C、D 并行。 +### 当前进展(`2026-04-21`) + +工作包 E 当前已完成 3 轮落地,真实状态如下: + +1. `customWorldAgentOrchestrator.ts` 已退化为后端应用服务 façade,只保留 session/message/action 主入口、operation 创建和下游服务委托;消息轮转、action 分发与派生状态重建已从热点文件中拆出。 +2. `CustomWorldAgentActionRegistry` 已正式接管 action 可用性校验、payload normalize、operation type 映射与 `supportedActions` 主链接线;前端不再需要按 action 字面量猜测按钮是否可点。 +3. `customWorldAgentActionExecutors/` 已补齐并接管以下真实执行链: + - `draft_foundation` + - `update_draft_card` + - `sync_result_profile` + - `generate_characters` + - `generate_landmarks` + - `generate_role_assets` + - `sync_role_assets` + - `generate_scene_assets` + - `sync_scene_assets` + - `expand_long_tail` + - `publish_world` + - `revert_checkpoint` +4. `CustomWorldAgentMessageTurnService`、`CustomWorldAgentSnapshotBuilder`、`CustomWorldAgentResultSyncService`、`CustomWorldAgentQualityGateService`、`CustomWorldAgentSuggestedActionService` 已形成稳定协作边界: + - message turn 负责会话轮转 + - snapshot builder 负责派生状态重建 + - result sync service 负责结果页回写 + - quality gate service 负责 `qualityFindings` + - suggested action service 负责建议动作 +5. 发布链已经统一切到 `CustomWorldAgentPublishingService`: + - orchestrator、executor map、publish executor、server 注入口径已经一致 + - 发布 readiness 与正式写库走同一服务 + - 作者展示名优先走 `resolveAuthorDisplayName`,同时保留 `userRepository` 兼容兜底 + - 发布产物 `profileId` 固定优先沿用 legacy 结果页 ID,否则回退为 `agent-draft-${sessionId}` +6. `sync_scene_assets` 已形成完整闭环: + - 营地/地点正式场景图会写回 draft profile + - 对应 `sceneChapters[].acts` 的 `backgroundImageSrc / backgroundAssetId` 会同步刷新 + - `rebuildRoleAssetCoverage()` 已补 camp/landmark 正式场景资产 fallback 汇总,确保 snapshot 重建、works 读模型与 checkpoint 回放都能保住场景资产覆盖状态 +7. checkpoint 已收口为“可恢复真快照”: + - `buildCheckpointSnapshot()` 已接入关键 executor + - `revert_checkpoint` 现在依赖真实 checkpoint snapshot 与 `restoreCheckpoint()` 主链完成回滚,不再是只开放入口的空动作 +8. `CustomWorldAgentActionRegistry` 已重新收口阶段策略: + - `sync_result_profile`、`generate_scene_assets`、`sync_scene_assets` 等精修动作仅允许 `object_refining / visual_refining` + - `expand_long_tail`、`publish_world`、`revert_checkpoint` 单独允许 `long_tail_review / ready_to_publish` +9. 已完成以下验证: + - `npm --prefix server-node run build` + - `npm --prefix server-node run test -- customWorldAgentPhase3.test.ts customWorldAgentActionRegistry.test.ts customWorldAgentPhase5.test.ts` + 当前 `server-node` 定向回归共 `208` 项通过,已覆盖工作包 E 第三轮的发布链、场景资产、长尾扩展与 checkpoint 回滚主链。 + +本轮刻意未做: + +1. 还没有进入 Phase 4 的“进入世界统一走发布态 gate”收口,当前只完成 `publish_world` 本身的后端闭环。 +2. 还没有改 `customWorldAgentSessionStore.ts` 与 repository 边界,这部分仍属于工作包 F。 +3. 还没有让前端结果页正式消费服务端 `resultPreview` 主链字段,这部分仍需要与工作包 G / H 协作。 +4. 旧 `customWorldAgentPublishGateService.ts`、`customWorldAgentPublishService.ts` 仍作为历史兼容文件保留,尚未进入物理删除阶段。 + ## 9.6 工作包 F:后端 session/store/repository 拆分 ### 目标 @@ -832,6 +939,24 @@ store 当前混合了: 与工作包 E 有接口协作关系,但可以并行推进,最终通过 façade 汇合。 +### 当前进展(`2026-04-21`) + +工作包 F 已完成以下拆分落地: + +1. 已新增 `server-node/src/services/rpg-agent-session-store/`,把 session record、compatibility、factory、repository adapter 从 `customWorldAgentSessionStore.ts` 中物理拆出。 +2. `customWorldAgentSessionStore.ts` 已退化为兼容 façade,保留原类名、原方法签名,并正式改为依赖 `RpgAgentSessionRepositoryPort`。 +3. 已新增 `server-node/src/repositories/RpgAgentSessionRepository.ts`、`server-node/src/repositories/RpgWorldProfileRepository.ts`、`server-node/src/repositories/rpgWorldRepositoryShared.ts`。 +4. `runtimeRepository.ts` 中的 custom world session/profile/gallery 读写已改成委托新仓储,runtime 大仓储开始向“通用 runtime façade”收口。 +5. 已新增 `server-node/src/services/RpgWorldWorkCoverResolver.ts`、`server-node/src/services/RpgWorldWorkSummaryAssembler.ts`、`server-node/src/services/RpgWorldWorkSummaryService.ts`,把 works 读模型的封面解析、条目组装与服务入口从 `customWorldWorkSummaryService.ts` 中拆出。 +6. `context.ts`、`server.ts`、`runtimeRoutes.ts`、`syncCustomWorldSavedProfileAssets.ts` 已切到直接注入和使用 `RpgAgentSessionRepository`、`RpgWorldProfileRepository`、`RpgWorldWorkSummaryService`。 +7. `customWorldAgentPhase2~5` 与 `customWorldWorkSummaryService.integration.test.ts` 已切到新的 session/profile 内存仓储端口,定向回归 21 项全部通过。 + +本轮刻意未做: + +1. `RuntimeRepositoryPort` 仍保留兼容 façade 与 custom world 相关旧方法,现阶段先稳住 story/runtime 其他调用方。 +2. `RuntimeRepository` 中的 runtime 快照同步编排还没有继续下沉,当前先完成 custom world 持久化与 works 读模型边界拆分。 +3. `customWorldAgentSessionStore.ts`、`customWorldWorkSummaryService.ts` 等旧命名 façade 仍保留,等待后续统一命名和兼容层清理阶段再删除。 + ## 9.7 工作包 G:后端 preview compiler 与 runtime profile 目录化 ### 目标 @@ -855,6 +980,33 @@ store 当前混合了: 与工作包 E、F 并行,但在主链接入前需要先和 E 对齐 preview contract。 +### 当前进展(`2026-04-21`) + +工作包 G 已完成以下落地: + +1. 已新增 `server-node/src/modules/custom-world/runtime-profile/` 目录入口,并把原 `runtimeProfile.ts` 退化为兼容 façade。 +2. 已把 runtime profile 进一步物理拆分到: + - `normalizeShared.ts` + - `normalizeRole.ts` + - `normalizeLandmark.ts` + - `normalizeSceneChapter.ts` + - `normalizeCamp.ts` + - `buildCompiledProfile.ts` + - `buildAttributeSchema.ts` + - `creatorIntentBridge.ts` +3. `runtimeProfileCompiler.ts` 已退化为兼容 façade,不再承载主实现。 +4. `RpgWorldPreviewCompiler.ts` 已从简单别名升级为服务端 preview compiler 入口,新增 preview envelope 输出能力。 +5. `packages/shared/src/contracts/rpgCreationPreview.ts` 已补 `RpgCreationPreviewSource`,并在 Phase 5 后把 session 结果页正式 source 收口为 `session_preview`。 +6. `customWorldAgentFoundationDraftService.ts` 的 LLM foundation draft 主生成链已改成“直接组装 draft 主字段 + 单独保留 `legacyResultProfile` 兼容快照”,不再通过 preview compiler 反解草稿主字段。 +7. 已新增 `server-node/src/services/RpgWorldPreviewCompiler.test.ts`、`server-node/src/services/customWorldAgentFoundationDraftService.test.ts`,并完成编码检查与工作包 G 定向回归验证。 + +本轮刻意未做: + +1. 还没有把 preview contract 从当前 runtime-profile 兼容载体升级成独立 view model。 +2. 还没有让 orchestrator、route、前端结果页正式消费 preview envelope。 +3. `legacyResultProfile` 仍作为结果页兼容快照保留,相关消费链还没有完全脱离 legacy profile 富字段。 +4. 兼容 façade `runtimeProfile.ts` / `runtimeProfileCompiler.ts` 仍保留,等待后续阶段统一清理。 + ## 9.8 工作包 H:共享契约与测试基建 ### 目标 @@ -878,6 +1030,34 @@ store 当前混合了: 可从工作包 A 开始先建骨架,随后跟随 B 到 G 持续补齐。 +### 当前进展(`2026-04-21`) + +工作包 H 已完成以下落地: + +1. 已把 `rpgAgentAnchors.ts`、`rpgAgentDraft.ts`、`rpgAgentActions.ts`、`rpgAgentSession.ts`、`rpgCreationPreview.ts`、`rpgCreationWorkSummary.ts` 从类型别名骨架推进为真实共享契约定义。 +2. 已把旧 `packages/shared/src/contracts/customWorldAgent.ts` 降级为兼容聚合出口,并补齐: + - `customWorldAgentAnchors.ts` + - `customWorldAgentDraft.ts` + - `customWorldAgentActions.ts` + - `customWorldAgentSession.ts` + - `customWorldResultPreview.ts` + - `customWorldWorkSummary.ts` + 让旧命名导入可以按分域文件渐进迁移,而不是继续依赖单一大文件。 +3. 已新增 `packages/shared/src/contracts/rpgCreationFixtures.ts`,补齐八锚点、foundation draft、session、preview、published profile、library、works 等共享样本,并把 fixture 接入 `packages/shared/src/index.ts` 统一导出。 +4. 已把 shared contract tests 接入 `vitest.config.ts`,并补齐 `packages/shared/src/contracts/rpgContracts.test.ts`,覆盖 session snapshot、preview envelope、published profile、works summary,以及旧命名兼容分文件的类型消费。 +5. 已新增 `server-node/src/services/RpgWorldPreviewCompiler.fixture.test.ts`、`server-node/src/services/RpgWorldWorkSummaryAssembler.fixture.test.ts`、`server-node/src/services/customWorldWorkSummaryService.integration.test.ts`,把 preview compiler、works assembler、works service 对共享 fixture 的消费纳入 unit / integration / regression 回归。 +6. 已补 `server-node/src/services/RpgWorldWorkSummaryService.ts` 兼容实现,确保 works 兼容入口与当前 `rpgWorldProfiles + customWorldAgentSessions` 读模型服务口径一致。 +7. `customWorldAgentOrchestrator.ts` 已新增统一 session snapshot 装配入口,当前普通拉取与 SSE message stream 返回的 session 字段口径开始收口。 +8. 服务端 `RpgWorldPreviewCompiler` 输出已正式接入 session snapshot 的 `resultPreview` 字段,并复用当前 `qualityFindings` 生成 preview `qualityFindings / blockers` 兼容输出。 +9. `rpg-agent-session-store/rpgAgentSessionCompatibility.test.ts` 已覆盖“compatibility 脱离 store 直接单测”的主链能力,Phase 2 的 session 兼容层开始具备独立回归保障。 +10. `src/app.test.ts` 已补“custom world agent stream message returns enriched session payload over sse”回归,session snapshot / resultPreview / supportedActions 的 HTTP 与 SSE 响应口径开始统一验证。 + +本轮刻意未做: + +1. 还没有批量迁移仓库里所有旧 `customWorldAgent.ts` 导入到 `rpgAgent* / rpgCreation*`。 +2. 还没有批量把前端结果页与自动保存链统一切到服务端 `resultPreview`。 +3. 还没有把服务端 preview contract 从 legacy profile 兼容载体升级成独立 view model。 + ## 9.9 并行推进关系 推荐并行顺序如下: @@ -986,6 +1166,23 @@ store 当前混合了: 2. 自动保存与 session 同步都基于服务端确认后的 preview 3. 结果页字段回退问题不再依赖前端兼容修补 +### 当前进展(`2026-04-21`) + +Phase 3 本轮已完成以下主链接线: + +1. 前端 `rpgCreationPreviewAdapter.ts` 已正式改成“优先读取 `session.resultPreview`,本地 `draftProfile -> legacy result profile` 只做 fallback”的薄适配层。 +2. `useRpgCreationSessionController.ts`、`useRpgCreationResultAutosave.ts`、`useRpgEntryLibraryDetail.ts` 所在的 Agent 结果页打开链、自动保存链、继续创作恢复链,已统一通过 `buildPreviewFromSession()` 消费服务端 preview。 +3. `RpgEntryFlowShellImpl.tsx` 当前传给结果页自动保存与创作入口恢复的 `buildDraftResultProfile` 已切到服务端 preview 主链,不再把前端本地编译结果当成正式真相源。 +4. 前端 fallback 编译实现已迁入 `src/services/rpg-creation/rpgCreationPreviewDraftFallback.ts`,旧 `src/services/customWorldAgentDraftResult.ts` 已退化为兼容 re-export,不再继续承载主实现。 +5. 已新增 `src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts`,补齐“服务端 `resultPreview` 优先于本地 draft fallback”的回归断言。 +6. `PreGameSelectionFlow.agent.interaction.test.tsx` 已补充“没有 `legacyResultProfile` 也能凭服务端 preview 打开 Agent 结果页”的交互回归,验证结果页主链已切到后端 preview。 + +本轮刻意未做: + +1. `src/services/customWorldAgentDraftResult.ts` 仍保留,但当前已退化为兼容 re-export;真实 fallback 编译实现已迁到 `src/services/rpg-creation/rpgCreationPreviewDraftFallback.ts`,尚未物理删除该兼容入口。 +2. `legacyResultProfile` 仍保留在 session draft 中参与兼容输出,本轮没有越界清理后端兼容字段。 +3. 结果页 UI 还没有显式消费 `qualityFindings / blockers / preview source` 做额外展示,当前先完成主数据源迁移,不扩大 UI 变更面。 + ## Phase 4:发布链、自动保存链、进入世界链统一 ### 目标 @@ -1004,6 +1201,50 @@ store 当前混合了: 2. works、library、enter world 三处状态语义一致 3. 发布失败可以给出明确 blocker 与恢复入口 +### 当前进展(`2026-04-21`) + +Phase 4 本轮已完成以下主链接线: + +1. 服务端 `customWorldAgentPublishingService.ts` 已补结构化 `evaluatePublishReadiness()`,把 publish blocker 从“只在发布时报错”提升为可供 session preview、结果页和 works 读模型统一消费的后端真相。 +2. `customWorldAgentOrchestrator.ts` 当前输出的 `session.resultPreview` 已补: + - `publishReady` + - `canEnterWorld` + - 基于发布门槛而不是仅 `qualityFindings` 生成的 `blockers` + 让结果页可以直接消费正式 gate 语义。 +3. `RpgWorldWorkSummaryAssembler.ts` 已把 works 读模型进一步收口: + - 已进入 `published` 阶段的 Agent session 不再继续以草稿项出现在 works 创作中心 + - draft works 新增 `blockerCount / publishReady` + - published works 明确输出 `canEnterWorld=true` +4. 前端 Agent 结果页已开始消费服务端 Phase4 状态: + - 结果页在 Agent 草稿未发布时把主 CTA 改成“发布并进入世界” + - 结果页会展示服务端 preview source、publish blockers、warning 数量 + - 有 blocker 时会禁用“发布并进入世界”按钮,不再让前端继续假装可以直接进入世界 +5. `useRpgCreationEnterWorld.ts` 与 `RpgEntryFlowShellImpl.tsx` 已把 Agent 结果页进入世界主链改成: + - 先 `sync_result_profile` + - 再执行后端 `publish_world` + - 发布成功后才进入世界 + 不再允许 Agent 草稿结果页绕开 publish gate 直接起游戏。 +6. `RpgEntryWorldDetailView.tsx` 已把作品详情页草稿态的主按钮改成“请先发布作品”,避免 detail 页继续暗示未发布作品可以直接开始游戏。 +7. 已补回归测试覆盖: + - 服务端 `customWorldAgentPhase4.test.ts` + - 服务端 `customWorldAgentPhase5.test.ts` + - 服务端 `RpgWorldWorkSummaryAssembler.fixture.test.ts` + - 前端 `CustomWorldResultView.test.tsx` + - 前端 `PreGameSelectionFlow.agent.interaction.test.tsx` +8. 作品库 detail 页的“发布到广场”入口已统一复用 Agent Phase4 publish gate: + - `/api/runtime/custom-world-library/:profileId/publish` 在命中 `agent-draft-${sessionId}` 且 session 真实存在时,不再直接绕过 gate 调 `publishOwnProfile()` + - 现在会先复用 `CustomWorldAgentPublishingService` 的 blocker 判断 + - publish 成功后同步把对应 session 推进到 `published` + - detail 页、works、gallery 三处发布态语义已对齐到同一条后端主链 +9. 已补 HTTP 级回归测试覆盖 detail publish 主链: + - 服务端 `app.test.ts` 已新增“agent-backed detail publish 在 blocker 存在时返回明确错误” + - 服务端 `app.test.ts` 已新增“agent-backed detail publish 成功后同步发布 profile 与 session” + +本轮刻意未做: + +1. 旧兼容作品草稿的 detail publish 还没有强行套入 Agent publish gate,当前只在 `agent-draft-${sessionId}` 且 session 真实存在时切换到统一发布链,避免在未补齐兼容映射前误伤历史作品。 +2. 运行态真正的“进入世界解析”仍然是前端把 profile 交给 runtime session bootstrap,本轮先完成 Agent 创作主链的 publish gate 收口与 UI 阻断,不扩大到 runtime 启动协议改造。 + ## Phase 5:兼容层清理 ### 目标 @@ -1022,6 +1263,27 @@ store 当前混合了: 2. 不再存在“前端本地编译 profile 才能自动保存”的依赖 3. 文档、契约、测试口径一致 +### 当前进展(`2026-04-21`) + +Phase 5 本轮已完成以下主链清理: + +1. 服务端已新增 `server-node/src/services/rpgCreationPreviewProfileBuilder.ts`,把“foundation draft + legacyResultProfile 富字段 + 最新草稿资产”的合并规则正式收回后端,preview 与 publish 开始复用同一套兼容编译口径。 +2. `customWorldAgentOrchestrator.ts` 当前产出的 `session.resultPreview` 已不再依赖前端本地 fallback: + - 预览 profile 改为基于服务端 `rpgCreationPreviewProfileBuilder` 构建 + - preview source 已从兼容期的 `legacy_custom_world_profile` 收口为正式主链值 `session_preview` +3. 前端 `rpgCreationPreviewAdapter.ts` 已改成只消费服务端 `session.resultPreview`,结果页、继续创作、自动保存、发布后进入世界所复用的 `buildPreviewFromSession()` 不再承担本地 `draftProfile -> result profile` 编译职责。 +4. 结果页与编辑器目录内部的旧 façade 依赖已继续收口,当前 RPG 创作目录内部不再通过已删除旧文件反向跳转结果页/编辑器/资产工坊主链。 +5. 前后端测试口径已同步切到 Phase 5: + - 前端 `rpgCreationPreviewAdapter.test.ts`、`PreGameSelectionFlow.agent.interaction.test.tsx` 已统一改为消费 `session_preview` + - 服务端 `RpgWorldPreviewCompiler.test.ts` 已新增“preview builder 保留 legacy 富字段并合并最新草稿资产”的回归 + - 服务端 `customWorldAgentPhase3.test.ts`、`customWorldAgentPhase4.test.ts`、`app.test.ts` 已把 preview source 断言更新为 `session_preview` + +本轮刻意未做: + +1. 后端 `legacyResultProfile` 兼容字段仍保留在 foundation draft / result sync / publishing service 中,当前只是把“如何消费它”统一后移到服务端 preview / publish compiler,而不是继续让前端主链本地重编译。 +2. 旧命名 façade 如 `customWorldAgentSessionStore.ts`、`customWorldWorkSummaryService.ts`、`runtimeProfile.ts` 仍保留,因它们还在后端兼容与模块边界层承担真实职责,不属于本轮必须删除项。 +3. shared contracts 中旧 `customWorld*` 分域兼容导出仍保留,当前只收口真实定义与 preview source 语义,不越界做全仓库导入迁移。 + --- ## 11. 本次执行约束 @@ -1047,3 +1309,193 @@ store 当前混合了: **session 真相源 -> 服务端 preview 编译 -> published profile 发布态** 只有这样,当前链路的可读性、可扩展性和后续功能落地稳定性,才会一起提升。 + +--- + +## 13. 2026-04-21 执行核查与老流程清理记录 + +本节用于记录本次按执行方案做的真实完成度核查、测试结果与老流程删除情况,避免“文档宣称已完成”和“代码真实状态”继续漂移。 + +### 13.1 本轮核查口径 + +本轮围绕以下 3 件事执行: + +1. 对照工作包 A / B / D / E / F / G / H 进度文档,核对真实代码入口与引用关系。 +2. 运行创作链相关与全量测试,确认当前主链真实可用范围。 +3. 只删除已经确认不再承载业务逻辑的旧流程桥接入口,不提前删除仍承担兼容编译责任的模块。 + +### 13.2 核查结论 + +当前可以确认: + +1. 工作包 B、D、E、F、G、H 的首轮主体拆分已经真实落地,且对应的新目录、hooks、client、service、repository、compiler 文件已存在。 +2. 工作包 C 的结果页、编辑器、资产工坊拆分也已基本落地到 `rpg-creation-result/`、`rpg-creation-editor/`、`rpg-creation-asset-studio/` 新目录。 +3. Phase 3、Phase 4、Phase 5 的主链接线与兼容层清理现已完成;当前剩余的是后端兼容字段与旧命名 façade 的保留问题,不能再把它们等同于“前端主链仍依赖老流程”。 + +### 13.3 本轮已物理删除的老流程入口 + +本轮已确认以下旧入口仅剩桥接职责,且完成引用迁移后可以安全物理删除: + +1. `src/components/game-shell/PreGameSelectionFlow.tsx` +2. `src/components/CustomWorldResultView.tsx` +3. `src/components/CustomWorldEntityEditorModal.tsx` + +同步完成的调用迁移包括: + +1. `GameShellMainContent.tsx` 已改为直接 lazy import `rpg-creation-flow` 新入口。 +2. `useGameShellViewModel.ts` 已改为直接从 `rpg-creation-flow` 取 `SelectionStage`。 +3. 结果页、编辑器与对应测试已切到 `rpg-creation-result/`、`rpg-creation-editor/` 新入口。 +4. `RpgCreationShellImpl.tsx` 已改为直接 lazy import `RpgCreationResultView` 新入口,不再回退到已删除旧结果页文件。 + +### 13.4 本轮明确不能删除的兼容层 + +以下模块本轮核查后确认仍在主链中承担真实兼容职责,暂时不能物理删除: + +1. `src/services/rpg-creation/rpgCreationPreviewAdapter.ts` +2. `server-node/src/services/customWorldAgentSessionStore.ts` +3. `server-node/src/services/customWorldWorkSummaryService.ts` +4. `server-node/src/services/customWorldAgentOrchestrator.ts` +5. `server-node/src/modules/custom-world/runtimeProfile.ts` + +原因分别是: + +1. `rpgCreationPreviewAdapter.ts` 仍是前端消费服务端 preview 的统一 façade,只是已经不再承担本地 fallback 编译。 +2. 后端仍通过 `legacyResultProfile` 参与阶段性结果回写与兼容输出。 +3. 多个旧命名 façade 仍被 server、context、tests 或 UI 入口直接引用。 + +### 13.5 本轮测试结果 + +已执行并确认结果如下: + +1. `npm run check:encoding` + 结果:通过。 +2. `npm --prefix server-node run test` + 结果:通过,`192` 项测试全部通过。 +3. `npm --prefix server-node run build` + 结果:通过。 +4. `npm --prefix server-node run test -- --test-name-pattern="action registry|phase5 publish_world|phase5 generate_scene_assets|phase5 publish_world blocks incomplete|phase5 revert_checkpoint|phase5 expand_long_tail"` + 结果:通过,`208` 项测试全部通过,已覆盖工作包 E 第三轮发布链、场景资产、长尾扩展与 checkpoint 回滚主链。 +5. `npm run test -- src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx src/components/CustomWorldResultView.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx` + 结果:通过,`34` 项测试全部通过。 +6. `npm run test` + 结果:失败,但失败点与本轮删除旧入口无直接关系;创作链相关定向回归已通过。 +7. `npm run build` + 结果:Vite 构建成功,但 build gate 因 chunk warning 失败,属于既有构建门禁问题。 +8. `npm run typecheck` + 结果:失败,存在 shared contracts、story contracts、runtime data、旧测试断言等既有类型问题,当前不适合作为本轮创作链清理通过口径。 +9. `npm --prefix server-node test -- src/services/customWorldAgentPhase3.test.ts src/services/customWorldAgentPhase4.test.ts src/services/customWorldAgentActionRegistry.test.ts src/services/RpgWorldPreviewCompiler.test.ts` + 结果:本轮新增的 `resultPreview` / `supportedActions` 主链断言已通过,但定向命令仍被一个既有 `customWorldAgentFoundationDraftService.test.ts` 断言失败带停,失败点与本轮 session snapshot 装配改动无直接耦合。 +10. `npm --prefix server-node run build` + 结果:通过。 +11. `npm run test -- src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx` + 结果:通过,`17` 项测试全部通过。 +12. `node --test --import tsx src/services/customWorldAgentActionRegistry.test.ts` + 结果:通过,`5` 项测试全部通过。 +13. `node --test --import tsx src/services/customWorldAgentPhase5.test.ts` + 结果:通过,`7` 项测试全部通过,已覆盖 `publish_world`、`generate_scene_assets`、`sync_scene_assets`、`expand_long_tail`、`revert_checkpoint` 的 Phase 5 主链回归。 +14. `node --test --import tsx src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.test.ts` + 结果:通过,`2` 项测试全部通过。 +15. `node --test --import tsx src/app.test.ts` + 结果:通过,`55` 项测试全部通过,包含 SSE enriched session 回归。 +15. `node --test --import tsx src/services/customWorldAgentPhase3.test.ts src/services/customWorldAgentPhase4.test.ts` + 结果:通过,`11` 项测试全部通过。 +16. `npm --prefix server-node run build` + 结果:通过。 +17. `npm run check:encoding` + 结果:通过,`1877` 个文件编码检查通过。 +18. `npm run test -- src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx` + 结果:通过,`20` 项测试全部通过;已验证前端结果页主链不再依赖本地 preview fallback。 +19. `node --test --test-concurrency=1 --import tsx src/services/customWorldAgentActionRegistry.test.ts src/services/customWorldAgentPhase3.test.ts src/services/customWorldAgentPhase5.test.ts` + 结果:通过,`16` 项测试全部通过;已验证 action registry 契约清理、Phase3 preview source 口径与 Phase5 发布链回归均正常。 + +### 13.6 当前全量阻塞项 + +截至 `2026-04-21` 本轮核查结束时,仓库仍存在以下全量阻塞: + +1. `server-node/src/app.test.ts` 存在未解决合并冲突。 +2. `src/hooks/story/npcEncounterActions.test.ts` 存在未解决合并冲突。 +3. 前端全量 Vitest 仍有 3 个失败用例: + - `src/components/game-shell/useGameShellRuntimeViewModel.test.ts` + - `src/data/functionCatalog/functionCatalog.test.ts` + - `src/hooks/story/npcEncounterActions.test.ts` +4. 前端全量 TypeScript 检查仍有多处既有错误。 +5. 前端 build gate 仍被大 chunk warning 阻断。 + +### 13.7 完成度判断 + +按执行方案分阶段判断,当前更准确的状态是: + +1. Phase 1:主体完成,并已开始物理清理前端旧入口。 +2. Phase 2:后端拆分主体完成,`snapshot / supportedActions / resultPreview / SSE enriched session / session compatibility` 主链都已有定向回归覆盖;但旧命名 façade 兼容层仍保留,且发布链统一语义尚未进入 Phase 4 收口态。 +3. Phase 3:主链接线已完成,前端结果页、自动保存与创作恢复入口已切到服务端 `resultPreview`;但本地 preview fallback 与 `legacyResultProfile` 兼容层仍保留,尚未进入 Phase 5 清理态。 +4. Phase 4:部分完成,`publish_world` 已有真实 executor 与 gate 接线,但 publish gate / enter world / works 状态语义还没有完全统一到后端正式发布态。 +4. Phase 4:主链完成。Agent 结果页、works 聚合、detail publish 与进入世界阻断已统一到后端正式发布态;当前剩余仅是 runtime 启动协议与旧兼容草稿映射,不再属于本阶段必须项。 +5. Phase 5:主链完成。前端本地 preview 编译桥、结果页旧入口影子引用、执行型废弃 action 契约已清理完成;当前剩余仅是后端 `legacyResultProfile` 兼容字段与旧命名 façade 保留,不再阻塞本阶段验收。 + +### 13.8 后续删除顺序建议 + +后续继续删除老流程代码时,应严格按下面顺序推进: + +1. 先完成 `qualityFindings / blockers / preview source` 的结果页与 gate 消费,把 Phase 4 所需阻断语义真正接到 UI 与进入世界链。 +2. 再按后端兼容迁移节奏收缩 `legacyResultProfile` 写回范围,而不是恢复前端本地 preview 编译桥。 +3. 再删除 `customWorldAgentSessionStore.ts`、`customWorldWorkSummaryService.ts`、`runtimeProfile.ts` 等旧命名 façade。 +4. 最后清理 `customWorld*` 旧契约聚合入口与剩余测试旧导入。 + +### 13.9 Phase 4 本轮追加落地(`2026-04-21`) + +本轮围绕 Phase 4 继续补齐了“发布链、自动保存链、进入世界链统一”的剩余断点: + +1. 服务端 `CustomWorldAgentPublishingService` 已新增统一的 publish gate 摘要出口,`resultPreview` 与 works 聚合现在复用同一套 `blockers / publishReady / canEnterWorld` 判断,不再各自重复拼门禁语义。 +2. `RpgWorldWorkSummaryAssembler` 已改为跳过 `stage === published` 的 session 草稿项,避免作品中心在正式发布后同时出现“已发布 profile + 已发布 session 草稿”双份状态。 +3. works 草稿项的 `publishReady / blockerCount` 已从“只看 qualityFindings”切到真实 publish gate 结果,作品中心、结果页与发布执行器开始共享同一套阻断口径。 +4. 前端 Agent 结果页继续沿用服务端 `resultPreview`,并在“发布并进入世界”成功后优先消费发布后的 preview/profile,而不是直接把 preview 原始对象强转成运行时 profile。 +5. 已补 `RpgWorldWorkSummaryAssembler.fixture.test.ts`、`customWorldWorkSummaryService.integration.test.ts` 与 `PreGameSelectionFlow.agent.interaction.test.tsx` 回归,覆盖 works 去重、publish gate 口径一致,以及“先发布再进入世界”主链。 +6. 共享 fixture 已补齐 `generatedSceneAssetId / publishReady / blockerCount / canEnterWorld` 等 Phase 4 口径字段,默认基线样本现在能够真实通过服务端 publish gate,避免 works / preview / 测试断言继续使用“前端自定义假 ready”状态。 +7. 前端“发布并进入世界”交互回归已改为状态驱动 mock:结果页打开前保持 `ready_to_publish`,仅在 `publish_world` 完成后切换为 `published`,从而覆盖 Phase 4 真实的“草稿结果页 -> 发布 -> 进入世界”顺序,而不是直接伪造已发布初始态。 + +本轮仍未完成: + +1. Agent 工作区内还没有独立的“发布世界”快捷入口,当前主入口仍在结果页。 +2. 旧兼容作品草稿的 detail publish 仍保留旧作品库接口,不属于本次 Agent Phase 4 主链统一范围。 + +### 13.10 老脚本依赖删除追加记录(`2026-04-21`) + +本轮按“不要再与老脚本有依赖”的口径继续执行物理清理,完成以下事项: + +1. 前端 RPG 创作主链已切到 `Rpg*` client 命名: + - `src/components/rpg-entry/useRpgCreationSessionController.ts` 直接调用 `createRpgCreationSession / getRpgCreationSession / streamRpgCreationMessage / executeRpgCreationAction` + - `src/components/rpg-entry/useRpgCreationResultAutosave.ts` 直接调用 `executeRpgCreationAction / getRpgCreationOperation / upsertRpgWorldProfile` +2. `src/services/rpg-creation/` 已删除旧命名导出: + - 不再导出 `createCustomWorldAgentSession / executeCustomWorldAgentAction / getCustomWorldAgentSession` + - 不再导出 `listCustomWorldWorks / upsertCustomWorldProfile / listCustomWorldLibrary` + - 不再导出结果页实体生成的 `generateCustomWorldPlayableNpc / generateCustomWorldSceneImage` 等兼容别名 +3. 旧 service 聚合入口已断开: + - `src/services/aiService.ts` 不再 re-export RPG 创作链能力 + - `src/services/storageService.ts` 已删除,运行时存档、设置、作品入口能力已迁入 `rpg-entry / rpg-runtime` 域 client +4. 旧组件入口继续物理删除: + - `src/components/CustomWorldRoleAssetStudioModal.tsx` + - `src/components/CustomWorldResultView.tsx` + - `src/components/CustomWorldEntityEditorModal.tsx` + - `src/components/game-shell/PreGameSelectionFlow.tsx` +5. 新组件入口已删除旧命名导出: + - `RpgCreationResultView.tsx` 只导出 `RpgCreationResultView` + - `RpgCreationEntityEditorModal.tsx` 只导出 `RpgCreationEntityEditorModal / RpgCreationEditorTarget` + - `RpgCreationRoleAssetStudioModal.tsx` 只导出 `RpgCreationRoleAssetStudioModal` +6. 已使用源码级扫描确认 `src / packages / server-node` 中不再存在以下旧主链符号引用: + - `createCustomWorldAgentSession` + - `executeCustomWorldAgentAction` + - `getCustomWorldAgentSession` + - `streamCustomWorldAgentMessage` + - `listCustomWorldWorks` + - `upsertCustomWorldProfile` + - `CustomWorldRoleAssetStudioModal` + - `CustomWorldResultView` + - `CustomWorldEntityEditorModal` +7. 本轮未删除 `CustomWorldProfile` 等历史数据结构类型名,也未删除 `server-node` 侧仍承担真实兼容职责的旧命名 façade;这些属于后端兼容字段与契约命名迁移,不再是前端老脚本依赖。 +8. 本轮验证结果: + - `npm run test -- src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts src/components/CustomWorldResultView.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` + 结果:通过,`42` 项测试全部通过。 + - `npm run test -- src/services/rpg-creation/rpgCreationGenerationClient.test.ts` + 结果:通过,`2` 项测试全部通过。 + - `npm run check:encoding` + 结果:通过,`1929` 个文件编码检查通过。 diff --git a/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md index f9e0627a..087aab57 100644 --- a/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md +++ b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md @@ -53,15 +53,18 @@ 已新增以下共享契约入口: 1. `packages/shared/src/contracts/rpgAgentAnchors.ts` -2. `packages/shared/src/contracts/rpgAgentSession.ts` -3. `packages/shared/src/contracts/rpgAgentActions.ts` -4. `packages/shared/src/contracts/rpgCreationPreview.ts` -5. `packages/shared/src/contracts/rpgCreationWorkSummary.ts` +2. `packages/shared/src/contracts/rpgAgentDraft.ts` +3. `packages/shared/src/contracts/rpgAgentSession.ts` +4. `packages/shared/src/contracts/rpgAgentActions.ts` +5. `packages/shared/src/contracts/rpgCreationPreview.ts` +6. `packages/shared/src/contracts/rpgCreationWorkSummary.ts` 当前策略: 1. 会话、动作、作品摘要先从旧 `customWorldAgent.ts` 做类型级兼容导出。 -2. `rpgCreationPreview.ts` 明确标记当前 preview 仍是 legacy profile 兼容载体,避免误认为 preview contract 已经完成。 +2. `rpgAgentDraft.ts` 先把 foundation draft、draft card 等草稿相关类型收口成独立入口,给工作包 H 后续物理拆分预留稳定导入点。 +3. `packages/shared/src/index.ts` 已补上对 RPG 草稿契约骨架的根导出,避免后续工作包继续回退到旧 `customWorldAgent.ts` 取类型。 +4. `rpgCreationPreview.ts` 明确标记当前 preview 仍是 legacy profile 兼容载体,避免误认为 preview contract 已经完成。 ## 3. 本次没有做的事 diff --git a/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..22c14101 --- /dev/null +++ b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md @@ -0,0 +1,106 @@ +# 创作流程链路重构工作包 B 完成记录 + +更新时间:`2026-04-21` + +## 1. 本轮目标 + +工作包 B 聚焦前端平台壳层与流程编排拆分,本轮目标是把平台壳层从“大编排文件”收口成“页面壳层 + 独立 hooks / coordinator”: + +1. `PreGameSelectionFlow.tsx` 退化为兼容入口。 +2. `RpgCreationShellImpl.tsx` 只保留 stage 切换、组件装配、视觉级 loading / error。 +3. 平台 bootstrap、session controller、operation polling、detail navigation、result autosave、enter-world 逻辑全部迁入 `src/components/game-shell/rpg-creation-flow/` 新目录。 +4. 保证现有交互测试继续通过,不引入主链行为回退。 + +--- + +## 2. 已完成内容 + +### 2.1 旧入口已退化为兼容层 + +`src/components/game-shell/PreGameSelectionFlow.tsx` 现在只保留: + +1. 旧类型导出兼容:`PreGameSelectionFlowProps`、`SelectionStage` +2. 旧组件名兼容:`PreGameSelectionFlow` +3. 对新实现 `RpgCreationShellImpl` 的桥接 + +这样现有调用方和测试仍可继续走旧路径,不会因为命名迁移立即破坏主链。 + +### 2.2 新目录已承接真实实现与流程 hooks + +已新增或更新以下文件: + +1. `src/components/game-shell/rpg-creation-flow/RpgCreationShellImpl.tsx` +2. `src/components/game-shell/rpg-creation-flow/RpgCreationShell.tsx` +3. `src/components/game-shell/rpg-creation-flow/index.ts` +4. `src/components/game-shell/rpg-creation-flow/rpgCreationFlowTypes.ts` +5. `src/components/game-shell/rpg-creation-flow/rpgCreationFlowShared.ts` +6. `src/components/game-shell/rpg-creation-flow/useRpgCreationPlatformBootstrap.ts` +7. `src/components/game-shell/rpg-creation-flow/useRpgCreationSessionController.ts` +8. `src/components/game-shell/rpg-creation-flow/useRpgCreationAgentOperationPolling.ts` +9. `src/components/game-shell/rpg-creation-flow/useRpgCreationDetailNavigation.ts` +10. `src/components/game-shell/rpg-creation-flow/useRpgCreationResultAutosave.ts` +11. `src/components/game-shell/rpg-creation-flow/useRpgCreationEnterWorld.ts` + +其中: + +1. `RpgCreationShell.tsx` 已不再桥接旧 `PreGameSelectionFlow`,而是直接桥接 `RpgCreationShellImpl.tsx`。 +2. `index.ts` 已开始从新目录导出 `SelectionStage`,为后续调用迁移准备统一出口。 + +### 2.3 平台编排已全部拆入独立 coordinator +本轮已经把原 `PreGameSelectionFlow` / `RpgCreationShellImpl` 中的主链编排拆到以下 hook: + +1. `useRpgCreationPlatformBootstrap.ts` + - 平台首页 works / library / gallery / history / save / dashboard 拉取 + - 浏览历史写入与存档恢复 +2. `useRpgCreationSessionController.ts` + - Agent session 创建 / 恢复 + - 消息流、action 执行、草稿生成态与结果页自动打开 +3. `useRpgCreationAgentOperationPolling.ts` + - Agent operation 轮询 + - 完成态 session 刷新与失败兜底 +4. `useRpgCreationDetailNavigation.ts` + - 作品详情、创作作品恢复、草稿结果页打开 + - 详情页发布 / 下架 / 删除 +5. `useRpgCreationResultAutosave.ts` + - 结果页自动保存 + - `sync_result_profile` 协调 + - 保存签名去重与延时保存 +6. `useRpgCreationEnterWorld.ts` + - 进入世界前的最终草稿同步 + +当前 `RpgCreationShellImpl.tsx` 只保留: + +1. hooks 组合 +2. stage 级视图切换 +3. 组件 props 装配 +4. 视觉级 loading / error 展示 + +--- + +## 3. 当前状态判断 + +工作包 B 已达到执行方案中的验收口径: + +1. `PreGameSelectionFlow.tsx` 只剩兼容导出与新壳层桥接。 +2. `RpgCreationShellImpl.tsx` 不再直接持有平台请求编排、operation 轮询、自动保存或进入世界同步细节。 +3. 平台侧主链已经切成壳层 + hooks / coordinator。 +4. 现有 `PreGameSelectionFlow.agent.interaction.test.tsx` 的 14 个场景全部通过。 + +--- + +## 4. 本轮刻意未做 + +1. 还没有物理删除 `PreGameSelectionFlow.tsx`,当前继续保留旧入口兼容层,避免影响并行工作包的调用路径。 +2. 还没有让所有调用方统一显式改走 `RpgCreationShell` 新入口,当前仍允许旧入口桥接到新壳层。 +3. 还没有把结果页 preview 数据源从前端兼容 adapter 切到服务端正式 preview contract;当前仍使用 `rpgCreationPreviewAdapter` 作为阶段性兼容层,这属于后续工作包 G / H 与 Phase 3 范围。 +4. 还没有清理所有 legacy 兼容导出与 façade,当前优先稳定主链与测试口径。 + +--- + +## 5. 验证结果 + +1. `npx eslint "src/components/game-shell/PreGameSelectionFlow.tsx" "src/components/game-shell/rpg-creation-flow/*.ts" "src/components/game-shell/rpg-creation-flow/*.tsx"` +2. `npx vitest run src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx` +3. `npm run check:encoding` + +以上检查在本轮修改后均已通过。 diff --git a/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..a7e9ac6d --- /dev/null +++ b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md @@ -0,0 +1,106 @@ +# 创作链路重构工作包 D 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次只落实 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 D:前端 custom world client 收口**,约束如下: + +1. 把创作链主路径依赖的 custom world 请求从 `aiService.ts`、`storageService.ts` 中迁入 `src/services/rpg-creation/`。 +2. 首轮允许旧 service 兼容导出,追加清理轮必须删除已无调用方的旧命名导出。 +3. 不改后端接口语义,不扩写结果页 UI 逻辑,不借机重构工作包 B / C 的内部状态编排。 + +## 2. 本次已落地内容 + +## 2.1 RPG 创作域请求基座已独立 + +已新增以下请求基座文件: + +1. `src/services/rpg-creation/rpgCreationRuntimeClient.ts` +2. `src/services/rpg-creation/rpgCreationRequestHelpers.ts` + +当前策略: + +1. runtime 读写重试策略不再散落在 `storageService.ts` 内部,而是作为 RPG 创作域专属 runtime client 复用。 +2. Agent SSE、POST JSON 请求辅助能力收口到 `rpgCreationRequestHelpers.ts`,避免再把流式解析细节写回通用 service。 + +## 2.2 五类 rpgCreation client 已持有真实请求实现 + +以下 client 已不再桥接旧 service,而是直接持有真实网络实现: + +1. `src/services/rpg-creation/rpgCreationAgentClient.ts` +2. `src/services/rpg-creation/rpgCreationWorkClient.ts` +3. `src/services/rpg-creation/rpgCreationLibraryClient.ts` +4. `src/services/rpg-creation/rpgCreationAssetClient.ts` +5. `src/services/rpg-creation/rpgCreationGenerationClient.ts` + +本轮已完成的具体收口: + +1. Agent session 创建、读取、消息发送、消息流、action 执行、operation 查询、card detail 查询已经正式迁入 `rpgCreationAgentClient.ts`。 +2. works 列表查询已经正式迁入 `rpgCreationWorkClient.ts`。 +3. library / publish / unpublish / gallery / gallery detail 已经正式迁入 `rpgCreationLibraryClient.ts`。 +4. 结果页与编辑器依赖的场景图、场景 NPC、可扮演角色、场景角色、场景生成请求已经正式迁入 `rpgCreationAssetClient.ts`。 +5. `generateCustomWorldProfile()` 已正式迁入 `rpgCreationGenerationClient.ts`,世界生成入口也已进入 RPG 创作域 client。 +6. `src/services/rpg-creation/index.ts` 已收口为 RPG 命名导出,创作主链不再从 barrel 暴露 `createCustomWorldAgentSession / listCustomWorldWorks / upsertCustomWorldProfile` 等旧命名入口。 + +## 2.3 旧 service 兼容导出已删除 + +追加清理轮已完成以下删除: + +1. `src/services/aiService.ts` 不再 re-export RPG 创作 Agent / works / 结果页生成接口,继续只服务 story/chat 等通用 AI 运行时能力。 +2. `src/services/storageService.ts` 已物理删除,运行时存档、设置、资料、浏览历史能力已迁入 `src/services/rpg-entry/` 与 `src/services/rpg-runtime/`。 +3. `rpgCreationAgentClient.ts`、`rpgCreationWorkClient.ts`、`rpgCreationLibraryClient.ts`、`rpgCreationAssetClient.ts` 已删除 `CustomWorld*` 兼容具名导出,只保留 `Rpg*` 主命名。 +4. 源码扫描已确认不再存在 `createCustomWorldAgentSession / executeCustomWorldAgentAction / listCustomWorldWorks / upsertCustomWorldProfile` 等旧主链函数引用。 + +## 2.4 主链调用已开始直接使用 RPG 创作域 client + +本轮已把以下主链入口切到 `src/services/rpg-creation/`: + +1. `src/components/rpg-entry/useRpgCreationSessionController.ts` +2. `src/components/rpg-entry/useRpgCreationResultAutosave.ts` +3. `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` +4. `src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx` +5. 新增世界生成入口 `generateRpgWorldProfile()` 通过 `src/services/rpg-creation/` barrel 暴露,后续新代码不必再从旧 `aiService.ts` 进入。 + +配套收口: + +1. 结果页与编辑器相关测试 mock 已改到 `rpgCreationAssetClient`,不再盯住 `aiService.ts` 的兼容层。 +2. `CustomWorldResultView.test.tsx`、`CustomWorldEntityEditorModal.test.tsx` 已改为直接消费 `RpgCreationResultView / RpgCreationEntityEditorModal` 新入口,不再通过旧组件 façade。 + +## 2.5 本轮验证结果 + +已完成以下针对性验证: + +1. `npm run test -- src/services/rpg-creation/rpgCreationGenerationClient.test.ts src/services/storageService.test.ts src/components/CustomWorldEntityEditorModal.test.tsx src/components/CustomWorldResultView.test.tsx src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx` +2. `npm run check:encoding` + +验证结果: + +1. 上述 5 组定向测试全部通过。 +2. 编码检查通过,未写坏中文文件。 + +追加清理轮已完成以下验证: + +1. `npm run test -- src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts src/components/CustomWorldResultView.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` + 结果:通过,`42` 项测试全部通过。 +2. `npm run test -- src/services/rpg-creation/rpgCreationGenerationClient.test.ts` + 结果:通过,`2` 项测试全部通过。 +3. `npm run check:encoding` + 结果:通过,`1929` 个文件编码检查通过。 +4. 源码扫描确认 `src / packages / server-node` 中不再存在本轮删除的旧主链函数与旧组件入口符号引用。 + +## 3. 本次刻意未做的事 + +以下内容明确留给后续工作包,不在本轮越界处理: + +1. 没有改后端 works/library/gallery/agent route 的语义与 contract。 +2. 没有拆 `PreGameSelectionFlow.tsx` 内部编排;这部分仍属于工作包 B。 +3. 没有继续物理拆散 `RpgCreationEntityEditorShared.tsx`;这部分仍属于工作包 C 后续细拆。 +4. 没有强行重命名历史数据结构类型,例如 `CustomWorldProfile` 与 runtime contract response 名称;这些仍是现有契约类型,不等同于旧脚本依赖。 +5. 没有删除旧 `src/services/ai.ts` 中的 legacy 世界生成实现;它已不在当前 RPG 创作主链 client 上,后续应按独立 dead code 批次评估。 + +## 4. 对后续工作包的直接收益 + +1. 工作包 B 后续拆平台壳层时,可以直接从 `src/services/rpg-creation/` 消费 Agent / works / library / gallery 请求,不必继续回到旧 service 文件找接口。 +2. 工作包 C 后续继续拆结果页和编辑器时,资产生成请求已经有稳定的 RPG 创作域入口。 +3. 后续清理 `aiService.ts`、`storageService.ts` 时,创作链主路径已经完成真实迁出,不会再被“通用 service 同时承载创作域请求”拖住。 diff --git a/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..24d4205d --- /dev/null +++ b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md @@ -0,0 +1,150 @@ +# 创作链路重构工作包 E 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次落实 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 E:后端 Agent 编排拆分**,并严格遵守这一轮的写入边界: + +1. 只改后端应用服务层,不动前端壳层。 +2. 先把 `customWorldAgentOrchestrator.ts` 从“大分支调度 + 派生状态重建 + 结果回写细节”里拆薄。 +3. 补齐 action executor 真实落点与 `supportedActions` 主链字段,但不在这一轮顺手重构 session store 和 runtime compiler。 + +## 2. 本次已落地内容 + +## 2.1 orchestrator 已退化为应用服务 façade + +本轮后,`server-node/src/services/customWorldAgentOrchestrator.ts` 的职责开始收口为: + +1. session 级入口方法保留。 +2. 创建 operation 记录。 +3. 调用 action registry 拿到执行计划。 +4. 把消息轮转、foundation 生成、实体生成、角色资产同步等主链事务串起来。 + +这轮明确移出的内容: + +1. `action -> executor` 的分支校验和分发。 +2. `sync_result_profile` 的字段回写细节。 +3. 多个 action 共用的 draftCards / assetCoverage / suggestedActions / qualityFindings 派生重建逻辑。 + +## 2.2 已新增 action registry 与 executor 目录,并完成真实执行迁移 + +已新增: + +1. `server-node/src/services/customWorldAgentActionRegistry.ts` +2. `server-node/src/services/customWorldAgentActionExecutors/index.ts` +3. `server-node/src/services/customWorldAgentActionExecutors/types.ts` + +本轮收口结果: + +1. registry 统一处理 `draft_foundation`、`update_draft_card`、`sync_result_profile`、`generate_characters`、`generate_landmarks`、`generate_role_assets`、`sync_role_assets` 的可用性校验。 +2. `publish_world`、`generate_scene_assets`、`sync_scene_assets`、`expand_long_tail`、`revert_checkpoint` 已完成真实 executor 装配,不再只是 registry 层面的“已声明但未开放”动作。 +3. `lock_cards`、`unlock_cards`、`regenerate_scope` 仍统一通过 registry 返回禁用原因,不再继续堆在 orchestrator 分支里。 +4. `customWorldAgentActionExecutors/` 已补 `draftFoundationExecutor.ts`、`updateDraftCardExecutor.ts`、`syncResultProfileExecutor.ts`、`generateCharactersExecutor.ts`、`generateLandmarksExecutor.ts`、`generateRoleAssetsExecutor.ts`、`syncRoleAssetsExecutor.ts`、`generateSceneAssetsExecutor.ts`、`syncSceneAssetsExecutor.ts`、`expandLongTailExecutor.ts`、`publishWorldExecutor.ts`、`revertCheckpointExecutor.ts`,真实 action 执行已从 orchestrator 物理迁入目录。 +5. `customWorldAgentActionExecutors/helpers.ts` 与 `executorShared.ts` 已收口 action_result / summary message 构造、operation 更新和 session 读取共用逻辑,避免 executor 间重复堆样板代码。 + +## 2.3 已新增 message turn / suggested action / snapshot / quality gate / result sync service + +已新增: + +1. `server-node/src/services/customWorldAgentMessageTurnService.ts` +1. `server-node/src/services/customWorldAgentSuggestedActionService.ts` +2. `server-node/src/services/customWorldAgentSnapshotBuilder.ts` +3. `server-node/src/services/customWorldAgentQualityGateService.ts` +4. `server-node/src/services/customWorldAgentResultSyncService.ts` + +本轮收口结果: + +1. `CustomWorldAgentMessageTurnService` 已接管 session 初始派生状态与 message turn 的真实执行,`customWorldAgentOrchestrator.ts` 只保留 façade 委托。 +1. `CustomWorldAgentSuggestedActionService` 统一维护 `foundation_review`、`object_refining`、`visual_refining` 的建议动作生成,不再散落在 orchestrator 和 session compatibility。 +2. `CustomWorldAgentSnapshotBuilder` 统一承接 message turn、foundation draft、结果页回写、角色/地点追加、角色资产同步后的派生字段重建。 +3. `CustomWorldAgentQualityGateService` 已形成独立 finding 入口,当前先输出角色缺失、地点缺失、玩家目标缺失、角色资产待补齐、场景资产待补齐等基础 gate finding。 +4. `CustomWorldAgentResultSyncService` 接管了 `sync_result_profile` 的字段回写细节,明确这一轮只允许“摘要 + 资产确认结果 + legacyResultProfile 快照”回写进 draft profile。 + +## 2.4 `supportedActions` 已接入 session snapshot 主链 + +这一轮已把 registry 产出的能力矩阵正式装配到 `CustomWorldAgentSessionSnapshot.supportedActions`: + +1. `createSession`、`getSessionSnapshot`、stream message 完成态、各 action 完成后的 session 拉取都会返回真实 `supportedActions`。 +2. `supportedActions` 的启用状态按 session 当前阶段与草稿可用性计算,不再由前端根据 action 字面量自行猜测。 +3. 具体 payload 校验仍保留在 action 执行阶段,能力矩阵只表达“当前阶段是否允许发起这类动作”。 + +## 2.5 action 主链行为保持不变,但派生状态已开始统一 + +这一轮没有改变现有 action contract,也没有新增前端依赖字段,但已经把以下重复派生逻辑统一改走 snapshot builder: + +1. `draft_foundation` +2. `update_draft_card` +3. `sync_result_profile` +4. `generate_characters` +5. `generate_landmarks` +6. `generate_role_assets` +7. `sync_role_assets` +8. message turn 结束后的 stage / suggested actions / quality findings / asset coverage 重建 + +这意味着: + +1. 后续新增 action 时,不必再复制一整段 `draftCards + assetCoverage + suggestedActions + recommendedReplies` patch 拼装代码。 +2. `qualityFindings` 已开始成为真实后端派生字段,而不只是 session store 中的空占位。 +3. `sync_result_profile` 的边界已经能单独测试和继续收缩。 + +## 2.6 工作包 E 第三轮已补齐的真实闭环 + +本轮把工作包 E 前两轮遗留的 5 个动作补成了真实后端闭环: + +1. `generate_scene_assets` + 已通过 `CustomWorldAgentAssetBridgeService.buildSceneAssetStudioContext()` 打通场景图工坊上下文准备,支持营地与地点单场景进入。 +2. `sync_scene_assets` + 已通过 `applySceneAssetPublishResult()` 写回营地/地点正式场景图,并同步刷新对应 `sceneChapters[].acts` 的背景图与背景资产 ID。 +3. `expand_long_tail` + 已接入实体生成服务与 snapshot builder,能真实追加长尾角色、地点并把阶段推进到 `long_tail_review`。 +4. `publish_world` + 已改为走 `CustomWorldAgentPublishingService + RpgWorldProfileRepository` 主链,正式把 draft session 编译、写入并发布到作品库。 +5. `revert_checkpoint` + 已依赖 checkpoint snapshot 元数据与 `restoreCheckpoint()` 主链完成真实回滚,不再只是开放 action 名称。 + +这一轮同时补齐了 4 个关键收口: + +1. 发布链已经统一改走 `CustomWorldAgentPublishingService`,`customWorldAgentOrchestrator.ts`、`customWorldAgentActionExecutors/index.ts`、`publishWorldExecutor.ts` 与 `server.ts` 的注入口径已经对齐;作者展示名优先走 `resolveAuthorDisplayName`,同时保留 `userRepository` 兼容兜底。 +2. `publish_world` 的 readiness 与正式发布已经收口到同一个服务,`profileId` 固定优先沿用 legacy 结果页 ID,否则回退为 `agent-draft-${sessionId}`,避免发布产物继续使用临时时间戳。 +3. `buildCheckpointSnapshot()` 已接入 `draft_foundation`、`update_draft_card`、`sync_result_profile`、`generate_characters`、`generate_landmarks`、`sync_role_assets`、`sync_scene_assets`、`expand_long_tail`、`publish_world` 等关键 executor,checkpoint 现在保存的是真正可恢复的派生快照,而不是只记一段残缺 patch。 +4. `rebuildRoleAssetCoverage()` 已补营地 / 地点正式场景资产 fallback 汇总,并收口为“只有真实正式场景图已存在时才补 standalone summary”,这样 `sync_scene_assets` 写回后的 camp/landmark asset coverage 在 snapshot 重建、works 读模型与 checkpoint 回放里都不会丢失,也不会误伤 phase3 自动资产回归。 + +## 2.7 本轮验证结果 + +已完成以下验证: + +1. `npm --prefix server-node run build` +2. `npm --prefix server-node run test -- customWorldAgentPhase3.test.ts customWorldAgentActionRegistry.test.ts customWorldAgentPhase5.test.ts` + +本轮重点关注的回归范围: + +1. `customWorldAgentActionRegistry.test.ts` +2. `customWorldAgentPhase3.test.ts` +3. `customWorldAgentPhase5.test.ts` +4. `publish_world` +5. `generate_scene_assets / sync_scene_assets` +6. `expand_long_tail` +7. `revert_checkpoint` + +验证结果: + +1. `server-node` 构建通过。 +2. 定向回归通过,共 `208` 项测试全部通过。 +3. Phase 3 与 Phase 5 已同时确认通过,说明这轮对 `sceneAssets` fallback summary 的收口没有打坏前序自动资产链。 + +## 3. 本次刻意未做的事 + +以下内容明确留给后续工作包或下一轮工作包 E,不在本轮越界处理: + +1. 还没有进入 Phase 4 的“进入世界统一走发布态 gate”收口,当前这轮只完成了发布动作本身的后端闭环。 +2. 还没有改 `customWorldAgentSessionStore.ts` 内部 compatibility / snapshot 输出结构,这部分仍属于工作包 F。 +3. 还没有把 result preview 正式接到 `resultPreview` 主链字段,这部分仍需要和工作包 G / H 协作。 +4. 旧 `customWorldAgentPublishGateService.ts` 与 `customWorldAgentPublishService.ts` 仍作为历史兼容文件保留,但工作包 E 主链已经不再走它们;这一轮没有继续做物理删除与引用清扫,避免越界碰到 Phase 4/Phase 5 之外的兼容入口。 + +## 4. 对后续工作包的直接收益 + +1. 工作包 F 可以在不碰 orchestrator 大分支的前提下,继续拆 session/store/repository。 +2. 工作包 G 可以直接围绕 `CustomWorldAgentResultSyncService` 和 `CustomWorldAgentQualityGateService` 对接服务端 preview compiler 与 publish gate。 +3. 工作包 H 可以基于已落地的 `supportedActions`、action registry 和 quality gate 继续推进 preview contract 与 contract tests。 +4. 后续继续拆 action executor 时,已经有 `customWorldAgentActionExecutors/` 目录和注册表,不需要再回到 orchestrator 里重新铺路。 diff --git a/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..072eb922 --- /dev/null +++ b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md @@ -0,0 +1,92 @@ +# 创作链路重构工作包 F 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次落实 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 F:后端 session/store/repository 拆分**,约束如下: + +1. 不改动现有主链接口与行为语义。 +2. 保留 `customWorldAgentSessionStore.ts`、`runtimeRepository.ts`、`customWorldWorkSummaryService.ts` 作为兼容 façade。 +3. 把 session 兼容补齐、session 持久化、profile 持久化、works 读模型组装从大文件中物理拆出。 + +## 2. 本次已落地内容 + +## 2.1 session store 内部分层 + +已新增以下 RPG Agent session 拆分文件: + +1. `server-node/src/services/rpg-agent-session-store/rpgAgentSessionRecord.ts` +2. `server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.ts` +3. `server-node/src/services/rpg-agent-session-store/rpgAgentSessionFactory.ts` +4. `server-node/src/services/rpg-agent-session-store/rpgAgentSessionRepositoryAdapter.ts` + +当前策略: + +1. `customWorldAgentSessionStore.ts` 继续保留旧类名和旧方法签名。 +2. sessionId 前缀、snapshot 输出结构、operation/checkpoint 写入语义保持兼容。 +3. 旧 session 的兼容补齐逻辑集中收口到 `rpgAgentSessionCompatibility.ts`,不再继续堆在 store 主文件里。 +4. `customWorldAgentSessionStore.ts` 已正式改为依赖 `RpgAgentSessionRepositoryPort`,phase2~5 与 works 集成测试也已切到新的 session 仓储端口。 + +## 2.2 custom world 仓储从 runtime 大仓储中拆出 + +已新增以下 RPG 世界仓储文件: + +1. `server-node/src/repositories/RpgAgentSessionRepository.ts` +2. `server-node/src/repositories/RpgWorldProfileRepository.ts` +3. `server-node/src/repositories/rpgWorldRepositoryShared.ts` + +当前策略: + +1. `RuntimeRepositoryPort` 继续保留兼容 façade,但 `context.ts`、`server.ts`、`runtimeRoutes.ts`、同步脚本已开始直接注入并使用 `RpgAgentSessionRepository`、`RpgWorldProfileRepository`。 +2. `runtimeRepository.ts` 内的 custom world session/profile/gallery SQL 已改成委托新仓储。 +3. `runtimeRepository.ts` 继续只保留 runtime 快照、设置、浏览历史、档案等通用能力,以及少量尚未迁走的快照同步编排。 + +## 2.3 works 读模型拆分 + +已新增以下 works 读模型相关文件: + +1. `server-node/src/services/RpgWorldWorkCoverResolver.ts` +2. `server-node/src/services/RpgWorldWorkSummaryAssembler.ts` +3. `server-node/src/services/RpgWorldWorkSummaryService.ts` + +并将: + +1. `server-node/src/services/customWorldWorkSummaryService.ts` + +退化为兼容入口,仅负责桥接新 `RpgWorldWorkSummaryService`。 + +当前策略: + +1. works service 只保留服务入口,不再内嵌标题、摘要、封面、资产覆盖率等全部组装细节。 +2. 草稿封面与发布态封面解析统一走 resolver,避免后续重复理解封面规则。 +3. 草稿态与发布态 work summary 的字段语义保持不变,继续支持“继续创作”和“进入世界”入口判定。 +4. `runtimeRoutes.ts` 中的 works/library/gallery 路由已切到 `rpgWorldWorkSummaryService` 与 `rpgWorldProfileRepository` 直接注入,不再经由 `runtimeRepository` 中转 custom world 读模型。 + +## 3. 验证结果 + +本次已完成以下定向回归: + +1. 运行 `node --test --test-concurrency=1 --import tsx src/services/customWorldAgentPhase2.test.ts src/services/customWorldAgentPhase3.test.ts src/services/customWorldAgentPhase4.test.ts src/services/customWorldAgentPhase5.test.ts src/services/customWorldWorkSummaryService.integration.test.ts` +2. 以上 21 个 custom world / agent / works 相关测试全部通过。 + +同时确认: + +1. 全量 `npx tsc -p server-node/tsconfig.json --noEmit` 当前仍被仓库里既有的跨模块类型问题阻塞。 +2. 这些全量类型错误大多与本工作包无关,因此本轮仍以 custom world 定向测试通过作为主验证口径。 +3. 工作包 F 本轮新增的 `RpgWorldWorkSummaryService.ts`、新仓储注入链和测试 helper,未在定向回归中引入新的行为回归。 + +## 4. 当前兼容保留项 + +以下内容属于阶段性兼容保留,不再视为工作包 F 未完成项: + +1. `RuntimeRepositoryPort` 仍保留 custom world 相关兼容方法,避免一次性冲击 story/runtime 其他调用方。 +2. `customWorldAgentSessionStore.ts`、`customWorldWorkSummaryService.ts` 仍保留旧文件名 façade,后续统一命名治理时再清理。 +3. runtime 快照同步与 custom world profile 自动回写的进一步解耦,仍留待后续围绕 `runtimeRepository.ts` 继续收口。 + +## 5. 对后续工作包的直接收益 + +1. 工作包 E 可以在不继续挤压 `customWorldAgentSessionStore.ts` 的情况下,把 orchestrator 的 result sync / snapshot builder 接到更清晰的 session 持久化边界。 +2. 工作包 G 后续若需要让 preview compiler / publish gate 落库,不必再继续往 `runtimeRepository.ts` 堆 custom world SQL。 +3. 工作包 H 已能直接围绕 `rpg-agent-session-store/`、`RpgWorldWorkSummaryAssembler.ts`、`RpgWorldWorkSummaryService.ts` 与新仓储端口补充更细粒度回归,而不必穿透大文件。 +4. 后续若继续拆 route 命名或清理旧 façade,已有 `context -> server -> runtimeRoutes -> script -> tests` 的新仓储注入链可直接复用。 diff --git a/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..7fd0f156 --- /dev/null +++ b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md @@ -0,0 +1,92 @@ +# 创作链路重构工作包 G 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次落实 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 G:后端 preview compiler 与 runtime profile 目录化**,并把目录化拆分推进到文档目标结构: + +1. 先把 `runtimeProfile.ts` 退化成兼容 façade。 +2. 把 `runtime-profile/` 真正拆成 `normalize/build/schema/creatorIntentBridge` 等独立模块。 +3. 把服务端 result preview compiler 从 foundation draft 流程中抽出独立入口。 +4. 不直接改路由层,不直接接前端结果页。 + +## 2. 本次已落地内容 + +## 2.1 runtime profile 已完成目录化完整拆分 + +已完成以下结构调整: + +1. 新增 `server-node/src/modules/custom-world/runtime-profile/index.ts` 作为目录入口。 +2. 原 `server-node/src/modules/custom-world/runtimeProfile.ts` 已退化为兼容 façade,只负责 re-export。 +3. `server-node/src/modules/custom-world/runtime-profile/runtimeProfileCompiler.ts` 已退化为兼容 façade,不再承载主实现。 +4. 已新增并落地以下目标模块: + 1. `normalizeShared.ts` + 2. `normalizeRole.ts` + 3. `normalizeLandmark.ts` + 4. `normalizeSceneChapter.ts` + 5. `normalizeCamp.ts` + 6. `buildCompiledProfile.ts` + 7. `buildAttributeSchema.ts` + 8. `creatorIntentBridge.ts` + +当前策略: + +1. 先保证旧导入路径不失效,避免放大工作包 G 首轮改动范围。 +2. 新代码优先改走 `runtime-profile/` 目录入口。 +3. `runtimeProfile.ts` 与 `runtimeProfileCompiler.ts` 后续只允许继续收缩,不再接受新增主逻辑。 + +## 2.2 服务端 preview compiler 已从 foundation draft 流程中抽出 + +已完成以下收口: + +1. `server-node/src/services/RpgWorldPreviewCompiler.ts` 不再只是别名导出,已提供: + 1. `buildRpgWorldPreviewProfile()` + 2. `normalizeRpgWorldPreviewProfile()` + 3. `buildRpgWorldPreviewEnvelope()` + 4. `normalizeRpgWorldPreviewEnvelope()` +2. `packages/shared/src/contracts/rpgCreationPreview.ts` 已补 `RpgCreationPreviewSource`,把 preview 来源语义显式化。 +3. `customWorldAgentFoundationDraftService.ts` 已把 LLM foundation draft 主生成链改成“直接组装 foundation draft + 单独保留 `legacyResultProfile` 兼容快照”,不再通过 preview compiler 反解草稿主字段。 + +这轮的边界变化是: + +1. foundation draft 主字段已经不再依赖“先编 legacy runtime profile,再转回 draft”的双重编译。 +2. `legacyResultProfile` 仍保留,但只作为结果页兼容快照,不再主导 foundation draft 生成。 +3. “服务端 preview 编译入口”继续独立存在,并在 Phase 5 后补上 `rpgCreationPreviewProfileBuilder.ts`,统一承接 preview 与 publish 的兼容合并规则。 +4. preview source 已在 Phase 5 后正式收口为 `session_preview`,不再继续沿用兼容期的 `legacy_custom_world_profile` 标记。 + +## 2.3 已补最小测试与目录化回归验证 + +本次新增: + +1. `server-node/src/services/RpgWorldPreviewCompiler.test.ts` +2. `server-node/src/services/customWorldAgentFoundationDraftService.test.ts` + +当前覆盖重点: + +1. 验证 preview compiler 可以输出服务端兼容预览 envelope。 +2. 验证 envelope 的 `source` 保持为 `session_preview`。 +3. 验证 preview profile 仍保留 runtime 编译生成的关键字段,例如 `scenarioPackId`、`campaignPackId`。 +4. 验证 Phase 5 新增的 preview builder 可以在服务端保留 `legacyResultProfile` 富字段并合并最新草稿资产。 +5. 验证 foundation draft service 的 LLM 路径已经直接生成 draft 主字段,不再依赖 preview compiler 反解。 +6. 验证 `runtimeProfile.ts` façade 在目录化拆分后仍保持旧调用兼容。 + +本轮额外验证已通过: + +1. `npm run check:encoding` +2. `node --test --test-concurrency=1 --import tsx server-node/src/services/customWorldAgentFoundationDraftService.test.ts server-node/src/modules/custom-world/runtimeProfile.test.ts server-node/src/services/RpgWorldPreviewCompiler.test.ts` + +## 3. 本次刻意没有做的事 + +以下内容仍留给后续阶段: + +1. 还没有让 `RpgWorldPreviewCompiler` 输出真正独立于 legacy profile 的 preview view model。 +2. 还没有把 `RpgWorldPreviewCompiler` 的 preview 载体从当前 runtime-profile 兼容对象升级成真正独立的 preview view model。 +3. `legacyResultProfile` 仍保留为兼容快照,结果页与自动保存链还没有完全脱离 legacy profile 富字段。 +4. 还没有删除 `runtimeProfile.ts`、`runtimeProfileCompiler.ts` 这两个兼容 façade。 + +## 4. 对后续工作包的直接收益 + +1. 工作包 E 可以围绕 `RpgWorldPreviewCompiler` 继续补 result sync / snapshot builder 的 preview 接口。 +2. 工作包 H 可以基于 `RpgCreationPreviewEnvelope` 继续细化正式 preview contract 和 contract tests。 +3. Phase 3 把结果页切到服务端 preview 时,已经有稳定的后端编译入口和目录化 normalize/build 模块,不需要再回头拆 `runtimeProfile.ts` 大文件。 diff --git a/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..2f8cd2f2 --- /dev/null +++ b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md @@ -0,0 +1,137 @@ +# 创作链路重构工作包 H 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次落实 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 H:共享契约与测试基建**,约束如下: + +1. 把 RPG 创作域共享契约从“类型别名骨架”推进到“真实定义 + 兼容出口”。 +2. 补齐可复用的 fixture,避免前后端测试继续各自复制一套假数据。 +3. 补齐 unit / contract / integration / regression 最小闭环,不越界重构 UI、路由和仓储主逻辑。 + +## 2. 本次已落地内容 + +## 2.1 共享契约已完成物理拆分与兼容收口 + +本轮已把以下文件从工作包 A 的骨架态推进为真实定义: + +1. `packages/shared/src/contracts/rpgAgentAnchors.ts` +2. `packages/shared/src/contracts/rpgAgentDraft.ts` +3. `packages/shared/src/contracts/rpgAgentActions.ts` +4. `packages/shared/src/contracts/rpgAgentSession.ts` +5. `packages/shared/src/contracts/rpgCreationPreview.ts` +6. `packages/shared/src/contracts/rpgCreationWorkSummary.ts` + +本轮收口重点: + +1. `rpgAgent*` 与 `rpgCreation*` 文件不再只是从旧 `customWorldAgent.ts` 做类型别名转发,而是承载真实契约定义。 +2. `rpgAgentSession.ts` 已显式加入 `supportedActions?` 与 `resultPreview?` 可选字段,为工作包 E/G 后续正式接入 registry 与服务端 preview compiler 预留稳定契约入口。 +3. `rpgCreationPreview.ts` 已补 `source / generatedAt / qualityFindings / blockers`,把“预览载体”和“预览来源/质量门槛”拆开。 +4. `rpgCreationWorkSummary.ts` 已收口 works 列表稳定字段,明确 `canResume / canEnterWorld` 的读模型语义。 + +## 2.2 旧 `customWorld*` 契约已补齐兼容分文件 + +本轮没有直接删除旧入口,而是把旧命名收口成“聚合出口 + 分文件兼容层”: + +1. 当前旧 `customWorldAgent.ts` 不再承载主定义,而是统一聚合: + - `customWorldAgentAnchors.ts` + - `customWorldAgentDraft.ts` + - `customWorldAgentActions.ts` + - `customWorldAgentSession.ts` + - `customWorldResultPreview.ts` + - `customWorldWorkSummary.ts` +2. 现有前后端直接导入 `customWorldAgent.ts` 的代码不需要在本轮一起大改,避免把工作包 H 扩成全仓导入迁移。 +3. 后续工作包可以逐步把新代码改到 `rpgAgent* / rpgCreation*` 路径;如果暂时仍需旧命名,也可以先切到更细的兼容分文件,而不是继续依赖单一大聚合文件。 + +## 2.3 已补共享 fixture,总线样本开始统一 + +本轮新增: + +1. `packages/shared/src/contracts/rpgCreationFixtures.ts` + +当前已提供并复用的样本包括: + +1. 八锚点 fixture +2. foundation draft fixture +3. session snapshot fixture +4. preview envelope fixture +5. published profile fixture +6. library entry fixture +7. works response fixture + +这些样本的作用是: + +1. 前端 contract test、后端 integration test、后续 preview/compiler 回归可以共用同一批样本。 +2. 避免继续在各测试文件里手写不一致的 session/profile/works 假数据。 +3. 把工作包 H 文档中要求的“最小 eight-anchor / preview / published profile / works 样本”先落成统一入口。 + +## 2.4 已补 unit / contract / integration / regression 最小闭环 + +本轮新增测试: + +1. `packages/shared/src/contracts/rpgContracts.test.ts` +2. `server-node/src/services/customWorldWorkSummaryService.integration.test.ts` +3. `server-node/src/services/RpgWorldPreviewCompiler.fixture.test.ts` +4. `server-node/src/services/RpgWorldWorkSummaryAssembler.fixture.test.ts` +5. `server-node/src/services/customWorldAgentActionRegistry.test.ts` +6. `server-node/src/services/customWorldAgentResultSyncService.test.ts` + +同时补充: + +1. `vitest.config.ts` 已把 `packages/shared/src/**/*.test.ts` 纳入前端 Vitest 测试入口。 +2. shared contract test 当前覆盖: + - session fixture、preview fixture、published profile fixture、works/library fixture 对齐关系 + - `supportedActions` 能力矩阵样本 + - 旧命名兼容分文件的类型消费 + - 角色动作资产、分幕背景、works 门槛字段不会在 fixture 演进时悄悄回退 +3. server unit / regression test 当前覆盖: + - preview compiler 可以直接消费 shared fixture + - works assembler 输出与 shared works fixture 保持一致 + - 角色主图、动作集、分幕背景资产字段在 normalize / assemble 后仍能保留 + - action registry 的 capability enable/disable 与 payload validate/normalize + - result sync service 只回写摘要与匹配资产,不让 runtime-only 结构反向污染 foundation draft +4. server integration test 当前验证共享 fixture 可以被 `customWorldWorkSummaryService` 正常消费,并输出和共享 works 响应样本一致的草稿/发布条目。 + +## 2.5 根导出已补齐 + +本轮已把: + +1. `packages/shared/src/contracts/rpgCreationFixtures.ts` +2. `packages/shared/src/contracts/customWorldAgent.ts` + +接入: + +1. `packages/shared/src/index.ts` + +这样后续前端和后端若要消费共享 fixture 或新契约,不需要再回退到旧单文件入口。 + +## 3. 本次验证结果 + +已完成以下定向验证: + +1. `npm run test -- packages/shared/src/contracts/rpgContracts.test.ts` +2. `node --test --test-concurrency=1 --import tsx src/services/customWorldAgentActionRegistry.test.ts src/services/customWorldAgentResultSyncService.test.ts src/services/customWorldWorkSummaryService.integration.test.ts src/services/RpgWorldPreviewCompiler.fixture.test.ts src/services/RpgWorldWorkSummaryAssembler.fixture.test.ts` +3. `npm run check:encoding` + +验证重点: + +1. shared 契约样本可直接通过 Vitest 执行。 +2. preview compiler、works assembler、works service 三层都可以直接消费 shared fixture,不需要额外复制一套测试数据。 +3. 中文文档与代码文件经过编码检查,没有把文本写坏。 + +## 4. 本次刻意未做的事 + +以下内容明确留给后续工作包或下一轮继续推进: + +1. 还没有把仓库里所有 `customWorldAgent.ts` 旧导入物理迁成 `rpgAgent* / rpgCreation*` 新导入。 +2. 还没有让后端 session snapshot 真正填充 `supportedActions`。 +3. 还没有让服务端 preview compiler 真正把 `resultPreview` 写入主链 snapshot。 +4. 没有改 UI、路由、数据库仓储或 orchestrator 主逻辑,严格控制在 shared contracts 与测试基建写入边界内。 + +## 5. 对后续工作包的直接收益 + +1. 工作包 E 可以直接复用 `supportedActions` 契约入口,把 action registry 的真实能力矩阵接进 session snapshot。 +2. 工作包 G 可以直接复用 `resultPreview` 和 `RpgCreationPreviewEnvelope`,继续把服务端 preview compiler 接回主链。 +3. 后续前后端测试都可以从 shared fixture 取样本,不需要继续维护多套彼此漂移的 session/profile 假数据。 +4. 旧命名导入可以先切到兼容分文件,再逐步替换到 `rpg*` 新契约,迁移路径更平滑。 diff --git a/docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md b/docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md index 90da8e88..abbff1b0 100644 --- a/docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md +++ b/docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md @@ -139,6 +139,25 @@ 4. `questDirector` / `runtimeItemAiDirector` 收缩为前端 SDK。 5. runtime story contract 开始补“随请求提交快照上下文”的后端承接能力,并把 NPC 待接委托 replace / abandon 接到后端。 +### 5.1 已完成 + +1. 鉴权 access/refresh session 已全部转为后端 Cookie 会话。 +2. `refreshSessionCookie` 已修复双 `Set-Cookie` 覆盖问题,登录/刷新/微信回调不再丢失 access cookie。 +3. 浏览历史已收敛为后端唯一真相,前端不再维护正式本地 browse history 链。 +4. runtime story 已支持随请求提交 snapshot,由后端内部解释与持久化。 +5. NPC 待接委托 `replace / abandon / accept` 已以后端 runtime action 为准。 +6. custom world profile 浏览器正式入口已改走后端 route。 +7. `questDirector` / `runtimeItemAiDirector` 已收缩为前端 SDK,不再承担正式浏览器编排。 +8. NPC 招募正式结算已迁到后端: + - 前端只负责招募对白展示与 release 目标选择 + - 后端负责 `npcStates / companions / roster / currentEncounter / storyHistory` 正式结算 + - 满员换队招募已由后端承接 + +### 5.2 剩余未完成 + +1. `src/services/ai.ts` 仍保留 legacy fallback / test 能力,尚未彻底压缩出正式浏览器主链。 +2. 仍需继续审视是否存在其他 NPC / 运行时分支,把正式状态裁决留在前端。 + --- ## 6. 验收标准 diff --git a/docs/technical/README.md b/docs/technical/README.md index 78ec122b..39a9fe86 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -16,7 +16,26 @@ - [AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md](./AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md):修正 Agent 对话框与结果页职责边界,明确 Agent 只收集八锚点,已有底稿的精修进入结果页完成。 - [CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md](./CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md):对照当前优化计划核查四阶段完成度,并明确这轮只允许物理删除旧 `custom-world/sessions` 世界生成链,不误伤 Agent 主链与已保存作品兼容编辑链。 - [CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md):梳理当前创作入口到结果页自动保存再到进入世界的全链前后端脚本地图,并给出文件级重构拆分方案、目标分层与阶段验收标准。 -- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md):记录创作链路重构工作包 A 已落地的 RPG 创作域目录骨架、兼容 façade 和共享契约入口。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md):梳理当前 RPG 从平台入口、继续游戏、角色选择到营地开场、冒险运行态与 runtime story 后端结算的全链脚本地图,并给出 RPG 专属命名规范、目标分层和可并行执行的工作包。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md):记录 RPG 进入游戏与运行时链路工作包 A 已完成的新目录骨架、前后端 façade、按域路由 path 常量与兼容仓储入口。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md):记录工作包 B 已完成的前端 RPG 入口壳层真实迁移、`rpg-entry` 新入口 hooks 收口,以及旧 `game-shell` / `rpg-creation-flow` 路径降级为兼容层的状态。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_C_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_C_PROGRESS_2026-04-21.md):记录工作包 C 已完成的 `rpg-session` 主链迁移、snapshot / save archive client 收口、旧 `useGame*` 降级为兼容 façade,以及定向回归结果。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md):记录工作包 D 已完成的前端运行态 shell / stage router / panel router 真实迁移、AdventurePanel section 拆分,以及旧 `GameShell*` 热点降级为兼容桥的现状。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md):记录 RPG 进入游戏与运行时链路工作包 F 已完成的后端 route 真正拆边界、`app.ts` 新域挂载、旧 `runtimeRoutes` / `storyActionRoutes` 兼容降级,以及定向路由回归验证结果。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md):记录工作包 E 已完成的前端 runtime story 主链真实迁移、NPC 交互与 gateway/client 收口、旧入口兼容降级,以及定向回归验证结果。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md):记录工作包 G 已完成的后端 runtime session / action service 物理迁移、新域原语导出、旧热点兼容降级,以及定向 runtime story 回归验证结果。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md):记录工作包 H 已完成的 RPG 运行时仓储拆分、shared runtime contract 分文件、旧 `story.ts` façade 兼容与定向回归结果。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PARALLEL_BATCH_AUDIT_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PARALLEL_BATCH_AUDIT_2026-04-21.md):对照执行计划逐项复核第一批与第二批并行工作的真实落地状态,记录本轮确认到的测试合流收口遗漏与文档索引补齐结果。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md):记录 RPG 执行计划第三批收口已完成的前端新域主链接回、后端新仓储接线、shared contract 直连收紧、旧兼容脚本物理删除,以及明确未扩到 UI 和无关历史文档的边界。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md):记录 RPG 主链旧 `GameShell`、`useGame*`、`hooks/story`、`runtimeRoutes`、`modules/story/*`、`contracts/story.ts` 脚本的物理删除范围、残留依赖扫描和定向验证结果。 +- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md):记录创作链路重构工作包 A 已落地的 RPG 创作域目录骨架、兼容 façade,以及补齐后的共享契约骨架入口。 +- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md):记录工作包 E 已完成的后端 Agent 编排拆分、executor 物理迁移、发布链切到 `CustomWorldAgentPublishingService`、checkpoint 真快照、场景资产 coverage 收口,以及 Phase3/Phase5 定向回归结果。 +- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md):记录工作包 H 已完成的共享契约物理拆分、旧命名兼容分文件、统一 fixture,以及 shared contract test / preview compiler / works assembler / works service 回归基建。 +- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md):记录工作包 B 已完成的前端平台壳层编排拆分、平台 hooks / coordinator 接入、旧入口兼容保留,以及交互回归验证结果。 +- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md):记录工作包 F 已完成的后端 session/store/repository 拆分、works 读模型 service 收口、route/context 直接注入新仓储,以及定向 custom world 回归验证结果。 +- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md):记录工作包 D 已完成的前端 custom world client 真正迁出、旧 service 兼容降级,以及平台壳层/结果页测试切换到 `rpgCreation` 域入口的现状。 +- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md):记录工作包 G 已完成的 runtime profile 目录化、服务端 preview compiler 收口,以及 foundation draft 主生成链与 preview 编译边界的直接拆开。 +- [CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md#93-工作包-c前端结果页与编辑器拆分](./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md#93-%E5%B7%A5%E4%BD%9C%E5%8C%85-c%E5%89%8D%E7%AB%AF%E7%BB%93%E6%9E%9C%E9%A1%B5%E4%B8%8E%E7%BC%96%E8%BE%91%E5%99%A8%E6%8B%86%E5%88%86):记录工作包 C 已完成的结果页壳层拆分、编辑器目标分发与 mapper 收口、角色资产工坊 section/workflow 拆分,以及仍保留的阶段性 shared 实现边界。 - [CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md](./CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md):创作页移动端底部 Tab、亮色主题 token 与滚动权责修复记录。 - [TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md](./TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md):把外部仓库 TXT 模式完整迁入当前项目的冻结边界、模块映射、分阶段计划与验收清单。 - [NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md](./NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md):当前 Node 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。 diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md new file mode 100644 index 00000000..ba2081ff --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md @@ -0,0 +1,999 @@ +# RPG 进入游戏与运行时链路重构执行方案 + +更新时间:`2026-04-21` + +## 0. 文档目标 + +本文只处理一件事: + +**把当前 RPG 玩法从“平台入口/继续游戏/世界详情开始游戏”到“角色选择/营地开场/冒险运行态/runtime story 后端动作结算”的整条前后端脚本链路,整理成一份可以直接指导后续并行重构的执行方案。** + +本轮不直接修改业务玩法,不新增需求,只明确: + +1. 当前链路上的真实脚本地图 +2. 当前命名、目录、边界和可读性问题 +3. 面向 RPG 类型游戏的专属命名规范 +4. 目标分层与文件级拆分建议 +5. 可同时并行推进的工作包与阶段验收标准 + +同时补充一条必须冻结的执行约束: + +**本次以及后续按本文推进的 RPG 链路重构,只允许调整脚本结构、命名、职责边界、数据流和兼容 façade,不允许修改任何前端交互界面设计。** + +--- + +## 1. 范围与依据 + +### 1.1 本文覆盖的 RPG 进入游戏链路 + +```text +平台首页 / 作品详情 / 继续游戏 +-> 选择世界或恢复存档 +-> 角色选择 +-> 初始化 GameState / Snapshot / Session +-> 营地开场 +-> 冒险运行态 shell +-> 冒险面板 / 角色面板 / 背包面板 +-> runtime story 选项解析与动作结算 +-> 服务端快照持久化 / 状态回写 / 继续游戏恢复 +``` + +### 1.2 本文主要依据 + +1. `docs/prd/AI_NATIVE_SCENE_CHAPTER_GAMEPLAY_PRD_AND_EXECUTION_PLAN_2026-04-08.md` +2. `docs/design/SCENE_CHAPTER_LOOP_AND_FIRST_ENTRY_CHAPTER_QUEST_DESIGN_2026-04-08.md` +3. `docs/experience/CURRENT_GAME_FULL_FLOW_PLAYTEST_REPORT_2026-04-07.md` +4. `docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md` +5. `docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md` +6. `docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md` + +### 1.3 本文刻意不覆盖的链路 + +本文不处理以下内容: + +1. RPG 创作流程链 +2. Agent 八锚点共创流程 +3. 自定义世界结果页编辑器内部资产工坊链 +4. 非 RPG 平台公共功能的全面改造 + +这些内容已有独立文档,本文只关注**进入 RPG 运行态之后的主玩法链**。 + +### 1.4 前端界面冻结约束 + +本次重构对前端界面的约束必须写死: + +1. 不修改任何前端交互界面设计。 +2. 不修改现有页面的视觉层级、主布局结构、按钮位置、tab 组织、弹窗/独立面板的出现方式。 +3. 不以“顺手优化体验”为理由调整入口页、选角页、冒险页、角色页、背包页的交互路径。 +4. 重构允许做的事情只包括:脚本重命名、目录迁移、hook/service 拆分、view model 收口、后端路由与服务拆分、兼容 façade 搭建。 +5. 如果个别脚本拆分必须调整 props 传递或组件装配方式,最终渲染结果与交互结果必须和当前版本保持一致。 +6. 任何会影响 UI 结构、交互节奏、面板开合形式的改动,都不属于本文工作范围,必须另开设计文档与实现任务。 + +--- + +## 2. 当前链路真实脚本地图 + +## 2.1 前端入口与进入世界链 + +| 文件 | 当前职责 | 当前问题 | +| --- | --- | --- | +| `src/App.tsx` | 应用入口,直接挂载 `useGameShellRuntime()` 与 `GameShellRuntime` | 入口极薄,但把“平台入口 + RPG 运行态”全部抽象成 `GameShell`,命名过泛 | +| `src/hooks/useGameShellRuntime.ts` | 串起 `useGameFlow`、`useGamePersistence`、`useStoryGeneration`、同伴与音乐逻辑,组装整套运行时 props | 已经是事实上的 RPG 主流程装配器,但命名仍像通用 shell | +| `src/hooks/useGameFlow.ts` | 世界选择、角色确认、`GameState` 初始化、营地遭遇创建 | 负责“进入游戏”的核心初始化,但文件名过泛,且把世界选择、选角、开局初始化混在一起 | +| `src/hooks/useGamePersistence.ts` | 远端快照加载、自动存档、保存退出、继续游戏恢复 | 快照加载、存档写入、恢复后 runtime story 刷新耦合在同一 hook 中 | +| `src/components/game-shell/PreGameSelectionFlow.tsx` | 平台首页、详情页、存档继续、创作入口、进入世界前流程壳层 | 仍承载过多平台级编排,不只负责 RPG 进入游戏链 | +| `src/components/game-shell/PlatformHomeView.tsx` | 平台首页、继续游戏、公开广场、存档/个人 tab 表现层 | 视觉层文件过大,且“平台首页”与“RPG 进入游戏入口”没有显式命名边界 | +| `src/components/game-shell/PlatformWorldDetailView.tsx` | 世界详情与“开始游戏/继续创作/发布”等操作 | `开始游戏` 是 RPG 入口动作,但当前文件名和职责仍偏平台通用详情页 | +| `src/components/game-shell/CharacterSelectionFlow.tsx` | 角色选择、角色自定义草稿、确认进入营地 | 已是纯 RPG 选角页面,但命名仍是泛化 `SelectionFlow` | + +## 2.2 前端运行态壳层与面板链 + +| 文件 | 当前职责 | 当前问题 | +| --- | --- | --- | +| `src/components/game-shell/GameShellRuntime.tsx` | 运行态最外层 shell,装配画布、主内容、各种 overlay | 同时承担平台主题外壳和 RPG 运行态外壳,命名与职责都偏泛 | +| `src/components/game-shell/GameShellMainContent.tsx` | 根据 `worldType / playerCharacter / selectionStage` 在平台、选角、冒险面板三种主阶段间切换 | 实际上是 RPG 主阶段路由器,但文件名没有表达“入口阶段切换” | +| `src/components/game-shell/GameShellStoryPanels.tsx` | 冒险/角色/背包三个主标签切换,挂载 `AdventurePanel`、`CharacterPanel`、`InventoryPanel` | 运行态主面板路由器与 tab 容器混在一起 | +| `src/components/AdventurePanel.tsx` | 冒险主面板、对话流、选项区、任务/设置/统计 overlay、NPC 聊天输入、奖励面板 | 单文件过大,是当前前端 RPG 运行时最大热点之一 | +| `src/components/game-shell/useGameShellRuntimeViewModel.ts` | 运行态视图模型、可见状态、过场、统计、对话指示器 | 负责运行态展示编排,但仍以 `GameShell` 泛名承载 | +| `src/components/game-shell/useGameShellViewModel.ts` | overlay/modal/选中实体/selectionStage 的壳层状态 | 进入游戏前与进入游戏后 UI 状态混在一起,命名不够领域化 | + +## 2.3 前端剧情运行时协调链 + +| 文件 | 当前职责 | 当前问题 | +| --- | --- | --- | +| `src/hooks/useStoryGeneration.ts` | 运行态故事主入口,拼装 runtime controller、goal session、interaction coordinator | 事实上的 RPG 叙事运行时入口,但命名过于抽象 | +| `src/hooks/story/useStoryRuntimeController.ts` | 当前故事、AI 错误、故事请求入口、fallback story 和 commit 动作 | 浏览器 AI 请求与服务端 runtime story 共同挂在同一 controller 上 | +| `src/hooks/story/useStoryFlowCoordinator.ts` | 汇总 goal option、interaction、session 行为,输出完整故事流程能力 | 多层 coordinator 套娃,可读性差 | +| `src/hooks/story/useStoryGoalSessionCoordinator.ts` | 任务领奖、重置 story、恢复 story、地图移动 | 任务会话动作与 story 生命周期控制混在一起 | +| `src/hooks/story/useStoryInteractionCoordinator.ts` | 选择分发、NPC 交互、宝藏交互、背包动作、战斗奖励、聊天输入 | 交互中心职责太重,是第二个热点文件 | +| `src/hooks/story/npcEncounterActions.ts` | NPC 聊天、切磋、委托接受/替换/放弃、服务端 runtime action、聊天 UI 细节 | 依然是巨型多职责文件,且混合 UI 组装、状态更新、服务端请求 | +| `src/hooks/story/runtimeStoryCoordinator.ts` | 继续游戏后恢复 runtime story、状态解析、server action 调用 | 是前端对接服务端 runtime story 的真正 gateway,但命名像临时协调器 | +| `src/hooks/story/storyRequestCoordinator.ts` | AI 故事请求参数编排、server option catalog 决策 | 与 runtime story 主链存在交叉语义,边界不直观 | +| `src/hooks/story/sessionActions.ts` | 地图移动、任务奖励、story reset/hydrate | “session action” 命名过泛,而且同时处理 quest/chapter/story 三类状态 | + +## 2.4 前端 service / client 链 + +| 文件 | 当前职责 | 当前问题 | +| --- | --- | --- | +| `src/services/storageService.ts` | 存档、设置、个人看板、浏览历史、作品库、继续游戏、世界详情 | RPG 快照/存档接口与平台资料/作品库接口混在一个通用 client 中 | +| `src/services/runtimeStoryService.ts` | `/api/runtime/story` 的状态读取、动作提交、story moment 转换 | 已接近 RPG 专属 client,但文件名仍偏通用 | +| `src/services/aiService.ts` | 初始剧情/续写、角色聊天、NPC 聊天、runtime item、quest、custom world 共创接口 | RPG 运行时 AI、角色聊天、创作 Agent 接口混在一起,领域过宽 | + +## 2.5 后端路由与运行时主链 + +| 文件 | 当前职责 | 当前问题 | +| --- | --- | --- | +| `server-node/src/server.ts` | 组装 `AppContext`,注入 runtimeRepository、customWorldAgentOrchestrator 等依赖 | 依赖对象过于集中,RPG 运行态缺少显式模块边界 | +| `server-node/src/app.ts` | 注册 `/api/auth`、`/api/runtime/story`、`/api` 等总路由 | RPG 进入世界链、平台路由、编辑器路由全部在总 app 中汇合,语义不够清晰 | +| `server-node/src/routes/runtimeRoutes.ts` | 资料、存档、浏览历史、作品库、runtime AI、世界生成等大杂糅路由 | 当前后端最大热点之一,平台资料与 RPG runtime 接口强耦合 | +| `server-node/src/modules/story/storyActionRoutes.ts` | runtime story 状态读取、动作结算 | 路由层本身还算薄,但命名仍然过于通用 | +| `server-node/src/modules/story/storyActionService.ts` | runtime story 状态/动作主应用服务,拼接 combat、npc、quest、treasure、LLM story | 是当前后端 RPG 运行时主热点,承担过多动作路由和 story 组装细节 | +| `server-node/src/modules/story/runtimeSession.ts` | runtime snapshot 归一化、option 构建、viewModel 编译、legacy currentStory 构建、rawGameState 同步 | 运行态编译中心过重,加载器、编译器、同步器、兼容层全部混在一起 | +| `server-node/src/modules/npc/npcInteractionService.ts` | NPC help/chat/fight/spar/recruit 等动作结算 | 与 runtime story 仍存在大量双向耦合 | +| `server-node/src/modules/quest/questStoryActionService.ts` | 委托接受、交付、待接委托读取与结算 | 已承担正式 quest 语义,但入口仍埋在 storyActionService 下游 | +| `server-node/src/repositories/runtimeRepository.ts` | 快照、存档列表、看板、浏览历史、作品库、会话等持久化读写 | 仓储过大,按技术分组而不是按 RPG 领域分组 | +| `server-node/src/modules/runtime/runtimeSnapshotHydration.ts` | 存档/gameState/currentStory 归一化、迁移补丁、默认值填充 | 既承担快照迁移,又承担业务字段补齐,是基础设施与领域逻辑混合点 | +| `packages/shared/src/contracts/story.ts` | 前后端 runtime story / npc chat / quest / runtime item 等共享契约 | RPG 运行时契约体量过大,story/action/state/chat 混放,难以独立演进 | + +--- + +## 3. 当前结构性问题 + +## 3.1 命名没有体现“这是 RPG 进入游戏主链” + +当前主链上充满以下泛化命名: + +1. `GameShell` +2. `MainContent` +3. `SelectionFlow` +4. `runtimeRoutes` +5. `storyActionService` +6. `sessionActions` + +这些命名的问题不是“不好看”,而是: + +1. 无法一眼区分平台入口、RPG 进入游戏、RPG 运行态 +2. 无法一眼看出文件属于前端壳层、状态协调器、还是后端应用服务 +3. 后续非 RPG 流程接入时,很容易继续误复用这些泛化热点文件 + +## 3.2 平台入口与 RPG 进入游戏链混在一起 + +`PreGameSelectionFlow.tsx` 当前同时承担: + +1. 平台首页 +2. 详情页 +3. 存档恢复 +4. 创作入口 +5. 进入世界 + +这会导致: + +1. 任何平台改动都可能碰到 RPG 进入游戏主链 +2. 任何 RPG 进入游戏改动都要穿过平台杂项状态 +3. 文件变成事实上的超大编排中心 + +## 3.3 `GameState` 初始化、快照恢复、运行态切入没有明确分层 + +`useGameFlow.ts`、`useGamePersistence.ts`、`useGameShellRuntime.ts` 当前共同承担: + +1. 新开局初始化 +2. 世界选择 +3. 角色选择 +4. 自动存档 +5. 继续游戏恢复 +6. runtime story 恢复刷新 + +结果是: + +1. “进入游戏前的 session bootstrap”与“进入游戏后的自动持久化”没有明确边界 +2. 继续游戏逻辑很难单独替换或扩展 +3. 任何存档策略变更都容易影响开局链 + +## 3.4 前端剧情运行时协调层过多,职责分散却仍然耦合 + +当前前端 story 主链至少经过: + +1. `useStoryGeneration` +2. `useStoryRuntimeController` +3. `useStoryFlowCoordinator` +4. `useStoryGoalSessionCoordinator` +5. `useStoryInteractionCoordinator` +6. `npcEncounterActions` +7. `runtimeStoryCoordinator` + +问题在于: + +1. 层数多,但不是稳定分层,而是热点文件之间互相穿透 +2. 有些层是 view model,有些层是 action dispatcher,有些层是 server gateway,命名看不出来 +3. 浏览器 AI 续写链与服务端 runtime story 链还没有完全收口为两个明确通道 + +## 3.5 后端路由层过于“大 runtime 大入口” + +`runtimeRoutes.ts` 当前同时覆盖: + +1. profile dashboard +2. browse history +3. save archives +4. custom world library/gallery +5. custom world profile generation +6. runtime story 外围 AI 接口 +7. runtime item / quest 生成接口 + +这会导致: + +1. RPG 进入游戏链难以抽出独立模块 +2. 平台资料接口和 RPG runtime 接口一起变更时风险高 +3. route 文件越来越像“后端总控清单” + +## 3.6 `runtimeSession.ts` 是当前后端最大可读性瓶颈之一 + +这个文件当前同时做了: + +1. snapshot 载入 +2. rawGameState 归一化 +3. option interaction 构建 +4. battle option 编译 +5. NPC option 编译 +6. viewModel 编译 +7. legacy currentStory 兼容输出 +8. rawGameState 回写同步 + +这会直接造成: + +1. 新增动作时很难判断应该改哪里 +2. 任何小调整都容易触碰多个职责 +3. 单测难以按职责拆开 + +## 3.7 持久化仓储按技术堆叠,没有按 RPG 域拆开 + +`runtimeRepository.ts` 把: + +1. snapshot +2. save archives +3. settings +4. dashboard +5. browse history +6. custom world library +7. custom world sessions + +全部堆在一起。 + +对 RPG 进入游戏链来说,至少应该显式分开: + +1. 运行时快照 +2. 存档归档 +3. 平台资料 +4. 世界库/详情 + +否则“继续游戏链”与“平台资料链”永远无法清晰拆边界。 + +--- + +## 4. 目标分层架构 + +## 4.1 目标原则 + +后续重构必须统一遵守 7 条原则: + +1. **平台入口只负责进入 RPG,会话真相不留在页面壳层。** +2. **世界选择、角色选择、新开局、继续游戏恢复属于 RPG session 入口域。** +3. **进入世界后的运行态壳层、冒险面板、story runtime 网关必须显式分层。** +4. **前端只保留展示状态、输入状态、UI 过场状态;正式快照、正式动作、正式 story option 以后端为准。** +5. **后端 route / application service / compiler / repository 必须按 RPG 域拆开,不再扩大“大 runtime 单文件”。** +6. **所有新命名都要显式表达“这是 RPG 类型游戏专属流程”,不能继续依赖 `GameShell / runtime / flow` 这类泛化词。** +7. **重构期间严格冻结前端交互界面设计,脚本重组不能改变任何页面结构与交互表现。** + +## 4.2 目标链路 + +```text +RPG 平台入口壳层 +-> RPG session bootstrap +-> RPG 角色选择 +-> RPG 运行态 shell +-> RPG 运行态面板路由 +-> RPG runtime story gateway +-> RPG runtime story routes +-> RPG runtime action/state services +-> RPG runtime session loader/compiler +-> RPG snapshot repository / save archive repository +``` + +## 4.3 推荐目录骨架 + +### 前端 + +```text +src/ +├─ components/ +│ ├─ rpg-entry/ +│ ├─ rpg-runtime-shell/ +│ ├─ rpg-runtime-panels/ +│ └─ rpg-runtime-overlays/ +├─ hooks/ +│ ├─ rpg-session/ +│ └─ rpg-runtime-story/ +└─ services/ + ├─ rpg-entry/ + └─ rpg-runtime/ +``` + +### 后端 + +```text +server-node/src/ +├─ routes/ +│ ├─ rpg-entry/ +│ ├─ rpg-profile/ +│ └─ rpg-runtime/ +├─ modules/ +│ └─ rpg-runtime-story/ +├─ services/ +│ ├─ rpg-entry/ +│ └─ rpg-runtime/ +└─ repositories/ + ├─ rpg-entry/ + └─ rpg-runtime/ +``` + +--- + +## 5. RPG 专属命名规范 + +## 5.1 命名根 + +后续进入游戏与运行态链统一使用以下命名根: + +1. `rpgEntry` + - 平台首页、详情页、世界进入、角色选择、继续游戏入口 +2. `rpgSession` + - 新开局、继续游戏恢复、快照 persistence、开局 bootstrap +3. `rpgRuntime` + - 游戏内 shell、tab、面板、overlay、view model +4. `rpgRuntimeStory` + - story state/action client、gateway、route、service、compiler +5. `rpgProfile` + - dashboard、browse history、save archive 等玩家资料域 + +## 5.2 命名规则 + +1. React 组件文件统一使用 `Rpg...` 前缀。 +2. hooks 统一使用 `useRpg...` 前缀。 +3. 前端 client/gateway/adapter 统一使用 `rpg...` 小驼峰前缀。 +4. 后端 route 使用 `rpg...Routes.ts`。 +5. 后端应用服务使用 `Rpg...Service.ts`。 +6. 后端编译器/装配器使用 `Rpg...Compiler.ts` / `Rpg...Assembler.ts`。 +7. 后端仓储使用 `Rpg...Repository.ts`。 +8. 共享契约文件优先拆成 `rpgEntry...`、`rpgRuntimeStory...`、`rpgProfile...`。 + +## 5.3 命名禁忌 + +后续重构中禁止继续新增以下主命名: + +1. `GameShell*` +2. `PreGame*` +3. `SelectionFlow*` +4. `runtimeRoutes.ts` 这种单文件总入口命名 +5. `storyActionService.ts` 这种过宽的单域名 +6. `sessionActions.ts`、`flowCoordinator.ts`、`manager.ts`、`helper.ts` 作为主业务模块名 + +## 5.4 关键文件重命名建议 + +| 当前文件 | 目标命名 | 说明 | +| --- | --- | --- | +| `src/components/game-shell/PreGameSelectionFlow.tsx` | `src/components/rpg-entry/RpgEntryFlowShell.tsx` | 平台进入世界与选角前阶段壳层 | +| `src/components/game-shell/PlatformHomeView.tsx` | `src/components/rpg-entry/RpgEntryHomeView.tsx` | RPG 平台首页 | +| `src/components/game-shell/PlatformWorldDetailView.tsx` | `src/components/rpg-entry/RpgEntryWorldDetailView.tsx` | 世界详情与开始游戏入口 | +| `src/components/game-shell/CharacterSelectionFlow.tsx` | `src/components/rpg-entry/RpgEntryCharacterSelectView.tsx` | RPG 选角页 | +| `src/hooks/useGameFlow.ts` | `src/hooks/rpg-session/useRpgSessionBootstrap.ts` | 新开局/世界选择/角色确认 | +| `src/hooks/useGamePersistence.ts` | `src/hooks/rpg-session/useRpgSessionPersistence.ts` | 自动存档/继续游戏恢复 | +| `src/hooks/useGameShellRuntime.ts` | `src/hooks/rpg-session/useRpgRuntimeSession.ts` | RPG 主运行态装配器 | +| `src/components/game-shell/GameShellRuntime.tsx` | `src/components/rpg-runtime-shell/RpgRuntimeShell.tsx` | 运行态总外壳 | +| `src/components/game-shell/GameShellMainContent.tsx` | `src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx` | 平台/选角/冒险阶段切换 | +| `src/components/game-shell/GameShellStoryPanels.tsx` | `src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx` | 冒险/角色/背包主标签路由 | +| `src/components/AdventurePanel.tsx` | `src/components/rpg-runtime-panels/RpgAdventurePanel.tsx` | 冒险主面板 | +| `src/hooks/useStoryGeneration.ts` | `src/hooks/rpg-runtime-story/useRpgRuntimeStory.ts` | 前端 story 运行态主入口 | +| `src/hooks/story/useStoryRuntimeController.ts` | `src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts` | 当前 story 与请求控制 | +| `src/hooks/story/useStoryFlowCoordinator.ts` | `src/hooks/rpg-runtime-story/useRpgRuntimeStoryFlow.ts` | story 主编排 | +| `src/hooks/story/useStoryInteractionCoordinator.ts` | `src/hooks/rpg-runtime-story/useRpgRuntimeInteractionFlow.ts` | runtime 交互分发 | +| `src/hooks/story/npcEncounterActions.ts` | `src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts` | NPC 交互与聊天动作 | +| `src/hooks/story/runtimeStoryCoordinator.ts` | `src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts` | 前端到后端 runtime story 网关 | +| `src/services/runtimeStoryService.ts` | `src/services/rpg-runtime/rpgRuntimeStoryClient.ts` | `/api/runtime/story` client | +| `src/services/storageService.ts` | 拆成 `rpgProfileClient.ts` / `rpgEntryLibraryClient.ts` / `rpgSnapshotClient.ts` | 按领域拆 client | +| `server-node/src/routes/runtimeRoutes.ts` | 拆成 `rpgProfileRoutes.ts` / `rpgEntryRoutes.ts` / `rpgRuntimeAiRoutes.ts` / `rpgWorldLibraryRoutes.ts` | 拒绝单文件总路由 | +| `server-node/src/modules/story/storyActionRoutes.ts` | `server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts` | runtime story 专属路由 | +| `server-node/src/modules/story/storyActionService.ts` | `RpgRuntimeStoryActionService.ts` + `RpgRuntimeStoryStateService.ts` | 动作结算与状态读取拆开 | +| `server-node/src/modules/story/runtimeSession.ts` | `RpgRuntimeSessionLoader.ts` + `RpgRuntimeOptionCompiler.ts` + `RpgRuntimeSnapshotSync.ts` | 按职责拆分 | +| `server-node/src/repositories/runtimeRepository.ts` | `RpgRuntimeSnapshotRepository.ts` 等多个仓储 | 按领域拆仓储 | +| `packages/shared/src/contracts/story.ts` | 拆成 `rpgRuntimeStory.ts` / `rpgRuntimeChat.ts` / `rpgRuntimeAction.ts` | 契约拆分 | + +--- + +## 6. 前端重构拆分方案 + +## 6.1 RPG 入口壳层拆分 + +### 当前问题 + +`PreGameSelectionFlow.tsx` 当前同时承担平台首页、详情页、进入世界、存档恢复与部分创作入口逻辑。 + +### 目标拆分 + +保留一个极薄的 `RpgEntryFlowShell.tsx`,只负责: + +1. 入口阶段切换 +2. 装配子页面 +3. loading / error 壳层 + +从当前文件拆出: + +1. `useRpgEntryBootstrap.ts` +2. `useRpgEntryNavigation.ts` +3. `useRpgEntrySaveResume.ts` +4. `useRpgEntryLibraryDetail.ts` +5. `RpgEntryHomeView.tsx` +6. `RpgEntryWorldDetailView.tsx` +7. `RpgEntryCharacterSelectView.tsx` + +### 关键要求 + +1. 入口壳层不再直接操作作品库、浏览历史、看板的多路加载细节。 +2. 入口壳层不再直接处理“继续游戏后刷新 runtime story”的逻辑。 +3. 创作入口与 RPG 进入世界入口要显式分段,不再共享大文件。 +4. 页面视觉结构、按钮布局、tab 形式和独立面板交互方式保持不变。 + +## 6.2 RPG session bootstrap / persistence 拆分 + +### 当前问题 + +`useGameFlow.ts` 与 `useGamePersistence.ts` 共同承担了开局初始化、世界进入、存档自动保存和恢复。 + +### 目标拆分 + +建议拆出: + +1. `useRpgSessionBootstrap.ts` +2. `useRpgCharacterEntry.ts` +3. `useRpgSnapshotPersistence.ts` +4. `useRpgContinueGame.ts` +5. `rpgSnapshotClient.ts` + +### 关键要求 + +1. 新开局初始化与继续游戏恢复是两条显式流程。 +2. `GameState` 初始化逻辑不再和自动存档逻辑放在同一 hook 里。 +3. 存档自动保存不再直接夹带 UI 层状态决策。 +4. 脚本拆分后不改变用户看到的进入游戏流程和交互顺序。 + +## 6.3 RPG 运行态 shell 与面板拆分 + +### 当前问题 + +`GameShellRuntime.tsx`、`GameShellMainContent.tsx`、`GameShellStoryPanels.tsx`、`AdventurePanel.tsx` 共同组成了一个过于耦合的壳层群。 + +### 目标拆分 + +建议形成: + +1. `RpgRuntimeShell.tsx` +2. `RpgRuntimeStageRouter.tsx` +3. `RpgRuntimePanelRouter.tsx` +4. `RpgAdventurePanel.tsx` +5. `RpgRuntimeOverlayHost.tsx` +6. `useRpgRuntimeShellViewModel.ts` + +### 关键要求 + +1. 运行态最外层壳层只管布局、背景、过场和 overlay host。 +2. 主阶段路由器只管“平台/选角/冒险”的分流。 +3. 面板路由器只管“冒险/角色/背包”的主 tab 分流。 +4. `AdventurePanel` 内部继续按“story 区 / option 区 / overlay 区”拆 section。 +5. 不重做任何面板样式、信息层次和交互布局,拆分只发生在脚本内部。 + +## 6.4 前端 runtime story 主链拆分 + +### 当前问题 + +前端 runtime story 主链层数太多,但边界并不稳定。 + +### 目标拆分 + +建议收成四层: + +1. `useRpgRuntimeStory.ts` + - 作为唯一对上输出入口 +2. `useRpgRuntimeStoryState.ts` + - 管当前 story、loading、error、hydration +3. `useRpgRuntimeInteractionFlow.ts` + - 分发 NPC / 战斗 / 宝藏 / 任务 / 地图动作 +4. `rpgRuntimeStoryGateway.ts` + - 只负责和后端 runtime story client 交互 + +### 关键要求 + +1. 浏览器侧 AI 续写逻辑与 server runtime story 逻辑必须显式分离。 +2. `npcEncounterActions.ts` 中的 UI 细节和正式动作结算必须拆开。 +3. 任何 `resolveServerRuntimeChoice(...)` 都应只通过统一 gateway 入口调用。 +4. 交互按钮、对话区、输入区和奖励展示的前端交互形式保持现状不变。 + +## 6.5 前端 service/client 收口方案 + +### 当前问题 + +`storageService.ts` 与 `aiService.ts` 的领域过宽。 + +### 目标拆分 + +建议新增: + +1. `src/services/rpg-entry/rpgEntryLibraryClient.ts` +2. `src/services/rpg-entry/rpgProfileClient.ts` +3. `src/services/rpg-runtime/rpgSnapshotClient.ts` +4. `src/services/rpg-runtime/rpgRuntimeStoryClient.ts` +5. `src/services/rpg-runtime/rpgRuntimeChatClient.ts` + +### 关键要求 + +1. `storageService.ts` 逐步降级为兼容 façade。 +2. `aiService.ts` 逐步只保留通用 AI 与创作域 client,不继续承接 RPG runtime story 主链。 +3. 进入游戏链不能再依赖过宽的通用 client 文件。 + +--- + +## 7. 后端重构拆分方案 + +## 7.1 route 层拆分 + +### 当前问题 + +`runtimeRoutes.ts` 过重,平台资料、作品库和 RPG runtime 都在一起。 + +### 目标拆分 + +建议拆成: + +1. `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts` +2. `server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts` +3. `server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts` +4. `server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts` +5. `server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts` + +### 关键要求 + +1. RPG 进入游戏链相关接口必须从“大 runtime 总路由”中抽离。 +2. `app.ts` 中要能一眼看出平台资料、世界库、runtime story 的路由边界。 +3. route 层继续保持薄,不直接承载 story 业务决策。 + +## 7.2 runtime story service 拆分 + +### 当前问题 + +`storyActionService.ts` 当前同时承担状态读取、动作结算、LLM story 包装和 snapshot 持久化。 + +### 目标拆分 + +建议拆为: + +1. `RpgRuntimeStoryStateService.ts` +2. `RpgRuntimeStoryActionService.ts` +3. `RpgRuntimeCombatActionService.ts` +4. `RpgRuntimeNpcActionService.ts` +5. `RpgRuntimeStoryPresentationCompiler.ts` +6. `RpgRuntimeStorySnapshotCommitService.ts` + +### 关键要求 + +1. 状态读取与动作结算分开。 +2. 各子域动作要能各自单测,不再全部挂在一个 service 里。 +3. LLM 二次包装 story 文本不能继续散在动作服务主文件中。 + +## 7.3 `runtimeSession.ts` 目录化拆分 + +### 当前问题 + +`runtimeSession.ts` 已经集中了太多运行时编译职责。 + +### 目标拆分 + +建议新增目录: + +```text +server-node/src/modules/rpg-runtime-story/session/ +├─ RpgRuntimeSessionLoader.ts +├─ RpgRuntimeEncounterNormalizer.ts +├─ RpgRuntimeOptionCompiler.ts +├─ RpgRuntimeBattleOptionCompiler.ts +├─ RpgRuntimeNpcOptionCompiler.ts +├─ RpgRuntimeViewModelCompiler.ts +├─ RpgRuntimeLegacyStoryAdapter.ts +└─ RpgRuntimeSnapshotSync.ts +``` + +### 关键要求 + +1. loader、compiler、legacy adapter、snapshot sync 必须物理拆开。 +2. “是否生成 legacy currentStory”要成为单独兼容层,而不是散落在核心编排里。 +3. interaction 语义编译要可单测、可扩展。 + +## 7.4 repository 拆分 + +### 当前问题 + +`runtimeRepository.ts` 不是 RPG 进入游戏链友好的结构。 + +### 目标拆分 + +建议拆成: + +1. `RpgRuntimeSnapshotRepository.ts` +2. `RpgSaveArchiveRepository.ts` +3. `RpgProfileDashboardRepository.ts` +4. `RpgBrowseHistoryRepository.ts` +5. `RpgWorldLibraryRepository.ts` + +### 关键要求 + +1. snapshot、save archive 与资料型仓储分离。 +2. 世界库详情读取与运行时快照读写不再耦合在同一仓储。 +3. 后续“继续游戏”链可以只依赖 snapshot/save archive 仓储。 + +## 7.5 shared contract 拆分 + +### 当前问题 + +`packages/shared/src/contracts/story.ts` 已经过大。 + +### 目标拆分 + +建议拆为: + +1. `rpgRuntimeStoryAction.ts` +2. `rpgRuntimeStoryState.ts` +3. `rpgRuntimeChat.ts` +4. `rpgRuntimeQuestAssist.ts` + +### 关键要求 + +1. runtime story 主链契约要能独立演进,不被 NPC chat / quest / item 附属能力拖累。 +2. 前后端只在必要范围共享契约,减少大而全文件。 + +--- + +## 8. 可并行重构工作包 + +本次执行计划必须拆成多个可以同时推进的工作部分。 + +总原则如下: + +1. 每个工作包只负责一组明确文件。 +2. 每个工作包先建新目录和 façade,再迁真实调用,再清旧层。 +3. 同一阶段允许并行,但禁止多人同时大改同一热点主文件。 + +## 8.1 工作包 A:RPG 命名规范与目录骨架 + +### 目标 + +先建立 RPG 进入游戏链的新命名与目录落点。 + +### 负责范围 + +1. `src/components/rpg-entry/` +2. `src/components/rpg-runtime-shell/` +3. `src/components/rpg-runtime-panels/` +4. `src/hooks/rpg-session/` +5. `src/hooks/rpg-runtime-story/` +6. `src/services/rpg-entry/` +7. `src/services/rpg-runtime/` +8. `server-node/src/routes/rpg-*/` +9. `server-node/src/modules/rpg-runtime-story/` +10. `server-node/src/repositories/rpg-*/` + +### 写入边界 + +1. 只建目录、入口 façade、导出索引和基础命名规范。 +2. 不负责大规模迁移老逻辑。 + +### 前置依赖 + +无,可立即开始。 + +## 8.2 工作包 B:前端 RPG 入口壳层拆分 + +### 目标 + +把平台首页/详情页/继续游戏/进入世界从 `PreGameSelectionFlow.tsx` 拆出来。 + +### 负责范围 + +1. `PreGameSelectionFlow.tsx` +2. `PlatformHomeView.tsx` +3. `PlatformWorldDetailView.tsx` +4. `CharacterSelectionFlow.tsx` +5. 入口阶段相关新 hooks + +### 写入边界 + +1. 只改前端入口链。 +2. 不改后端接口语义。 +3. 不拆运行态冒险面板。 +4. 不修改任何前端交互界面设计。 + +### 前置依赖 + +依赖工作包 A 的目录骨架。 + +## 8.3 工作包 C:前端 session/bootstrap/persistence 拆分 + +### 目标 + +把 `useGameFlow.ts`、`useGamePersistence.ts`、`useGameShellRuntime.ts` 收成 RPG session 域。 + +### 负责范围 + +1. `useGameFlow.ts` +2. `useGamePersistence.ts` +3. `useGameShellRuntime.ts` +4. `storageService.ts` 中 snapshot/save archive 相关调用入口 + +### 写入边界 + +1. 主要改 session bootstrap、自动存档、继续游戏恢复。 +2. 不改 AdventurePanel UI。 +3. 不改后端 storyActionService 语义。 +4. 不修改任何前端交互界面设计。 + +### 前置依赖 + +依赖工作包 A,可与 B、D、F、G、H 并行。 + +## 8.4 工作包 D:前端运行态 shell 与面板拆分 + +### 目标 + +把 `GameShellRuntime.tsx`、`GameShellMainContent.tsx`、`GameShellStoryPanels.tsx`、`AdventurePanel.tsx` 拆成 RPG runtime shell 体系。 + +### 负责范围 + +1. 运行态 shell +2. 主阶段路由器 +3. 面板路由器 +4. 冒险主面板 section + +### 写入边界 + +1. 主改组件与 view model。 +2. 不改 runtime story 后端协议。 +3. 不改平台入口链。 +4. 不修改任何前端交互界面设计。 + +### 前置依赖 + +依赖工作包 A,可与 B、C、E、F、G、H 并行。 + +## 8.5 工作包 E:前端 runtime story 与 NPC 交互链拆分 + +### 目标 + +把多层 story coordinator 收成稳定的 RPG runtime story 结构。 + +### 负责范围 + +1. `useStoryGeneration.ts` +2. `useStoryRuntimeController.ts` +3. `useStoryFlowCoordinator.ts` +4. `useStoryGoalSessionCoordinator.ts` +5. `useStoryInteractionCoordinator.ts` +6. `npcEncounterActions.ts` +7. `runtimeStoryCoordinator.ts` +8. `runtimeStoryService.ts` + +### 写入边界 + +1. 只改前端 runtime story 主链。 +2. 不拆平台首页和世界详情壳层。 +3. 不直接修改后端动作语义。 +4. 不修改任何前端交互界面设计。 + +### 前置依赖 + +依赖工作包 A,可与 B、C、D、F、G、H 并行。 + +## 8.6 工作包 F:后端 route 边界拆分 + +### 目标 + +把 `runtimeRoutes.ts` 与 `storyActionRoutes.ts` 按 RPG 域拆边界。 + +### 负责范围 + +1. `app.ts` +2. `runtimeRoutes.ts` +3. `storyActionRoutes.ts` +4. 新 `rpgProfileRoutes.ts` +5. 新 `rpgEntry...Routes.ts` +6. 新 `rpgRuntimeStoryRoutes.ts` + +### 写入边界 + +1. 只调整路由组织和 façade。 +2. 不重写下游全部 service 逻辑。 + +### 前置依赖 + +依赖工作包 A,可与 B、C、D、E、G、H 并行。 + +## 8.7 工作包 G:后端 runtime session / action service 拆分 + +### 目标 + +把 `storyActionService.ts` 与 `runtimeSession.ts` 目录化拆开。 + +### 负责范围 + +1. `storyActionService.ts` +2. `runtimeSession.ts` +3. `npcInteractionService.ts` +4. `questStoryActionService.ts` +5. 新 action service / compiler / adapter 文件 + +### 写入边界 + +1. 主改后端运行时 story 主链。 +2. 不改前端入口与 UI。 +3. 不负责仓储拆分。 + +### 前置依赖 + +依赖工作包 A,可与 B、C、D、E、F、H 并行。 + +## 8.8 工作包 H:仓储、契约与测试基建 + +### 目标 + +把 snapshot/profile/library 仓储与 shared contract 收成独立层,并补齐测试。 + +### 负责范围 + +1. `runtimeRepository.ts` +2. `runtimeSnapshotHydration.ts` +3. `packages/shared/src/contracts/story.ts` +4. runtime story / snapshot / continue game 相关测试 + +### 写入边界 + +1. 只负责仓储、契约、fixture、测试。 +2. 不直接改前端 UI。 +3. 不重写 route 层。 + +### 前置依赖 + +依赖工作包 A,可与 B、C、D、E、F、G 并行。 + +## 8.9 推荐并行顺序 + +```text +第一批并行: +工作包 A + +第二批并行: +工作包 B + 工作包 C + 工作包 D + 工作包 E + 工作包 F + 工作包 G + 工作包 H + +第三批收口: +把 B~H 的 façade 接回主链 +-> 联调继续游戏 / 开始游戏 / 角色选择 / 冒险 runtime / 存档恢复 +-> 清理旧命名与兼容导出 +``` + +## 8.10 并行协作约束 + +1. 工作包 B 独占 `PreGameSelectionFlow.tsx`、`PlatformHomeView.tsx`、`PlatformWorldDetailView.tsx`、`CharacterSelectionFlow.tsx`。 +2. 工作包 C 独占 `useGameFlow.ts`、`useGamePersistence.ts`、`useGameShellRuntime.ts`。 +3. 工作包 D 独占 `GameShellRuntime.tsx`、`GameShellMainContent.tsx`、`GameShellStoryPanels.tsx`、`AdventurePanel.tsx`。 +4. 工作包 E 独占 story runtime hooks 与 `runtimeStoryService.ts`。 +5. 工作包 F 独占 `runtimeRoutes.ts`、`storyActionRoutes.ts`、`app.ts`。 +6. 工作包 G 独占 `storyActionService.ts`、`runtimeSession.ts` 与相关 runtime story modules。 +7. 工作包 H 独占 `runtimeRepository.ts`、`runtimeSnapshotHydration.ts` 与 shared runtime story contracts。 + +--- + +## 9. 分阶段落地计划 + +## Phase 0:冻结命名与边界口径 + +### 目标 + +先冻结“RPG 入口链 / RPG session / RPG runtime / RPG runtime story / RPG profile”五类命名边界。 + +### 验收标准 + +1. 后续新增文件不再继续进入 `GameShell*`、`runtimeRoutes.ts`、`storyActionService.ts` 这类旧热点。 +2. 团队对文件落点和命名根达成一致。 +3. 团队对“脚本重构不改前端交互界面设计”的冻结边界达成一致。 + +## Phase 1:目录骨架与前端入口拆分 + +### 目标 + +先建立目录骨架,并把进入游戏前的前端壳层从大文件中拆开。 + +### 验收标准 + +1. 平台首页/详情页/选角能落到 `rpgEntry` 目录。 +2. `PreGameSelectionFlow.tsx` 退化为兼容壳层或 façade。 +3. 页面视觉结构与交互方式和当前线上版本保持一致。 + +## Phase 2:session/bootstrap/persistence 收口 + +### 目标 + +把“新开局/继续游戏/自动存档/恢复 runtime story”从组件与面板层抽离。 + +### 验收标准 + +1. `useGameFlow.ts`、`useGamePersistence.ts` 不再是直接主入口命名。 +2. 继续游戏与自动存档都能走独立 session hook。 +3. 用户看到的开始游戏/继续游戏交互顺序不变。 + +## Phase 3:运行态 shell 与 runtime story 主链拆分 + +### 目标 + +把冒险运行态 UI 壳层与 runtime story 协调层分别重构。 + +### 验收标准 + +1. `AdventurePanel.tsx` 不再是大一统主热点。 +2. 前端 runtime story 主链收成 3~4 个稳定层级,而不是多层 coordinator 套娃。 +3. 冒险态界面布局、tab 交互与 overlay 呈现方式不变。 + +## Phase 4:后端 route / service / compiler / repository 目录化 + +### 目标 + +把后端“大 runtime route + 大 storyActionService + 大 runtimeSession + 大 runtimeRepository”按 RPG 域拆开。 + +### 验收标准 + +1. `runtimeRoutes.ts` 退化为 façade 或被拆空。 +2. `storyActionService.ts` 只保留兼容导出。 +3. `runtimeSession.ts` 只保留 façade 或兼容导出。 +4. `runtimeRepository.ts` 不再承载全部 RPG 资料与快照职责。 +5. 前端交互界面设计在整个后端重构阶段保持零变更。 + +## Phase 5:兼容层清理 + +### 目标 + +在主链稳定后清理旧命名与旧 façade。 + +### 验收标准 + +1. 进入游戏主链只剩 RPG 专属命名文件。 +2. `GameShell*` 与 `runtimeRoutes.ts` 不再是主链真实入口。 +3. 文档、契约、测试口径一致。 + +--- + +## 10. 验收标准 + +本次重构方案最终要达成以下结果: + +1. 从“平台首页/详情页开始游戏”到“进入 RPG 运行态”的脚本链能一眼看出前端入口、session、runtime、story 的层级。 +2. 进入游戏主链不再依赖过于泛化的 `GameShell / runtime / flow` 命名。 +3. 前端自动存档、继续游戏恢复、runtime story server gateway 具备独立文件边界。 +4. 后端 route、runtime story service、session compiler、snapshot repository 分层清晰。 +5. 所有新主文件都以 RPG 域命名,不再继续扩大旧热点文件。 +6. 整个重构过程不改变任何前端交互界面设计,用户可见交互保持一致。 + +--- + +## 11. 结论 + +当前 RPG 进入游戏后的主玩法链,真正的问题不是单点 bug,而是: + +**平台入口、会话初始化、运行态壳层、前端 runtime story 协调链、后端 route 总入口、后端 runtime session 编译器和仓储层同时过重,而且命名没有体现“这是 RPG 专属主链”。** + +后续正确的重构方向不是继续在 `GameShell`、`runtimeRoutes.ts`、`storyActionService.ts` 这些热点文件上打补丁,而是把主链收成: + +**RPG 入口域 -> RPG session 域 -> RPG runtime shell 域 -> RPG runtime story 域 -> RPG snapshot / profile 域** + +只有这样,这条“开始游戏 -> 进入运行态 -> 推进剧情 -> 自动存档 -> 继续游戏”的链路,才会真正具备后续可读、可扩、可并行维护的工程形态。 diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md new file mode 100644 index 00000000..105f9bef --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md @@ -0,0 +1,107 @@ +# RPG 进入游戏与运行时链路旧脚本删除收口记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本轮继续按 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 收口,目标只有一个: + +**在新 `rpg-entry`、`rpg-session`、`rpg-runtime-shell`、`rpg-runtime-panels`、`rpg-runtime-story` 主链已经接回后,物理删除旧 RPG 入口与运行态脚本,并确认源码和当前后端构建产物不再依赖旧路径。** + +本轮不改 UI 布局、不改按钮位置、不改 tab 组织、不改弹层交互方式,也不把历史审计文档里的旧文件名当成运行时代码依赖处理。 + +## 2. 已删除旧脚本范围 + +前端旧入口与旧兼容层已从源码中移除: + +1. `src/components/game-shell/` +2. `src/components/game-shell/rpg-creation-flow/` +3. `src/components/AdventurePanel.tsx` +4. `src/hooks/story/` +5. `src/hooks/useGameFlow.ts` +6. `src/hooks/useGamePersistence.ts` +7. `src/hooks/useGameShellRuntime.ts` +8. `src/hooks/useStoryGeneration.ts` +9. `src/services/runtimeStoryService.ts` +10. `src/services/storageService.ts` + +后端与共享契约旧入口已从源码中移除: + +1. `server-node/src/routes/runtimeRoutes.ts` +2. `server-node/src/modules/story/runtimeSession.ts` +3. `server-node/src/modules/story/storyActionRoutes.ts` +4. `server-node/src/modules/story/storyActionService.ts` +5. `packages/shared/src/contracts/story.ts` + +## 3. 新主链落点 + +删除旧脚本后,RPG 主链只允许继续落在以下新域: + +1. 前端入口:`src/components/rpg-entry/` +2. 前端 session:`src/hooks/rpg-session/` +3. 前端运行态 shell:`src/components/rpg-runtime-shell/` +4. 前端运行态面板:`src/components/rpg-runtime-panels/` +5. 前端 runtime story:`src/hooks/rpg-runtime-story/` +6. 前端 client:`src/services/rpg-entry/`、`src/services/rpg-runtime/` +7. 后端 route:`server-node/src/routes/rpg-entry/`、`server-node/src/routes/rpg-profile/`、`server-node/src/routes/rpg-runtime/` +8. 后端 runtime story:`server-node/src/modules/rpg-runtime-story/` +9. 后端仓储:`server-node/src/repositories/rpg-entry/`、`server-node/src/repositories/rpg-profile/`、`server-node/src/repositories/rpg-runtime/` +10. 共享契约:`packages/shared/src/contracts/rpgRuntimeStoryAction.ts`、`packages/shared/src/contracts/rpgRuntimeStoryState.ts`、`packages/shared/src/contracts/rpgRuntimeChat.ts`、`packages/shared/src/contracts/rpgRuntimeQuestAssist.ts` + +## 4. 残留依赖检查 + +本轮使用旧路径和旧入口名扫描源码与当前后端构建产物,确认以下路径不存在且没有运行时代码引用: + +1. `src/components/game-shell` +2. `src/hooks/story` +3. `src/hooks/useGameFlow.ts` +4. `src/hooks/useGamePersistence.ts` +5. `src/hooks/useGameShellRuntime.ts` +6. `src/hooks/useStoryGeneration.ts` +7. `src/services/runtimeStoryService.ts` +8. `src/services/storageService.ts` +9. `server-node/src/routes/runtimeRoutes.ts` +10. `server-node/src/modules/story/runtimeSession.ts` +11. `server-node/src/modules/story/storyActionRoutes.ts` +12. `server-node/src/modules/story/storyActionService.ts` +13. `packages/shared/src/contracts/story.ts` + +补充处理: + +1. `npm --prefix server-node run build` 已重新生成当前 `server-node/dist/server.cjs`。 +2. 旧的忽略产物 `server-node/dist/server.js` 与 `server-node/dist/server.js.map` 是 `2026-04-18` 遗留 bundle,仍包含旧路径 sourcemap,本轮已定点删除,避免本地误跑旧构建。 +3. 当前 `server-node/dist/` 只保留 `server.cjs` 与 `server.cjs.map`,旧主入口路径扫描无命中。 + +## 5. 本轮补丁 + +本轮额外修正了迁移后测试 prop 类型不一致的问题: + +1. `src/components/rpg-runtime-panels/RpgAdventurePanel.npcChat.test.tsx` +2. `src/components/rpg-runtime-panels/RpgAdventurePanel.test.tsx` + +`npcChatQuestOfferUi.replacePendingOffer` 在新面板类型中是同步布尔返回,测试 mock 已从 `async () => false` 改为 `() => false`,避免旧异步 mock 继续伪装成兼容层行为。 + +## 6. 验证结果 + +已通过: + +1. `npm run check:encoding` +2. `npm --prefix server-node run build` +3. `npx vitest run src/components/rpg-runtime-panels/RpgAdventurePanel.npcChat.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.test.tsx src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts src/components/rpg-entry/RpgEntryCharacterSelectView.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx src/components/rpg-runtime-shell/useRpgRuntimeShellViewModel.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeInteractionFlow.test.ts` + +定向前端回归结果:`8` 个测试文件、`37` 个测试通过。 + +未完全通过但不属于本轮 RPG 旧脚本删除阻塞: + +1. `npm run typecheck` + - 剩余错误集中在 `packages/shared/src/contracts/rpgCreationFixtures.ts`、`src/components/auth/AccountModal.test.tsx`、`src/data/customWorldLibrary.ts`、`src/services/customWorldCover.test.ts`。 + - 本轮已清掉 `RpgAdventurePanel*.test.tsx` 中与迁移相关的 `Promise` 类型错误。 +2. `npm --prefix server-node run test` + - RPG runtime story、RPG entry save、RPG world library、RPG profile route 等相关测试通过。 + - 剩余失败为 `custom world agent` HTTP 相关 `12` 个用例返回 `500 !== 200`,属于创作链/Agent 链路问题,不属于本轮 RPG 旧脚本删除范围。 + +## 7. 结论 + +当前 RPG 进入游戏与运行时主链已经不再依赖旧 `GameShell`、`useGame*`、`useStoryGeneration`、`hooks/story`、`runtimeStoryService`、`runtimeRoutes`、`modules/story/*`、`contracts/story.ts` 脚本。 + +后续如果继续开发 RPG 入口、运行态、runtime story 或存档恢复,只应扩展新 `rpg-*` 目录与分文件契约,不应重新创建旧路径 façade。 diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PARALLEL_BATCH_AUDIT_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PARALLEL_BATCH_AUDIT_2026-04-21.md new file mode 100644 index 00000000..1642c367 --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PARALLEL_BATCH_AUDIT_2026-04-21.md @@ -0,0 +1,132 @@ +# RPG 进入游戏与运行时链路重构第一批第二批并行工作复核记录 + +更新时间:`2026-04-21` + +## 1. 复核目标 + +本次复核只做一件事: + +**对照 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md`,检查第一批并行与第二批并行工作是否存在遗漏、未完成收口或仍停留在“看起来已完成但工程状态未闭合”的问题。** + +执行边界保持如下: + +1. 只检查工作包 A 到 H 是否达到各自文档声明的落地状态。 +2. 只补齐确认属于本次重构范围的遗漏项。 +3. 不顺手扩展 UI、玩法、协议或无关模块。 + +## 2. 复核范围 + +本次逐项核对了以下内容: + +1. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` +2. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md` +3. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md` +4. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_C_PROGRESS_2026-04-21.md` +5. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md` +6. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md` +7. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md` +8. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md` +9. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md` +10. 前端 `rpg-entry`、`rpg-session`、`rpg-runtime-shell`、`rpg-runtime-panels`、`rpg-runtime-story` 新域真实实现 +11. 后端 `routes/rpg-*`、`modules/rpg-runtime-story`、`repositories/rpg-*` 新域真实实现 +12. shared contract 分文件与兼容 façade +13. 与工作包 E / F / G / H 直接相关的定向测试与工作树状态 + +## 3. 复核结论 + +## 3.1 第一批并行结论 + +第一批并行只包含工作包 A。 + +复核结果: + +1. 工作包 A 要求的目录骨架、façade、barrel 与按域命名落点已经建立。 +2. 工作包 A 没有发现需要继续补做的编码遗漏。 +3. 当前 A 的剩余内容本来就属于后续工作包 B 到 H 的真实迁移,不属于遗漏。 + +结论: + +**第一批并行无新增遗漏项。** + +## 3.2 第二批并行总体结论 + +对照工作包 B 到 H 的进度文档与实际主链代码,当前状态如下: + +1. 工作包 B:`rpg-entry` 已承接真实入口实现,旧 `game-shell` / `rpg-creation-flow` 路径已降级为兼容层。 +2. 工作包 C:`rpg-session` 已承接真实 session / persistence 主链,`storageService.ts` 已退化为兼容转发层。 +3. 工作包 D:`rpg-runtime-shell` 与 `rpg-runtime-panels` 已承接真实实现,旧 `GameShell*` / `AdventurePanel` 已降级为兼容入口。 +4. 工作包 E:前端 runtime story 主链已迁入 `rpg-runtime-story`,但测试工作树存在未收口的合流状态。 +5. 工作包 F:`app.ts` 已按 `rpgProfile / rpgEntry / rpgRuntimeStory / rpgRuntimeAiAssist` 挂载新域路由,旧 `runtimeRoutes.ts` 已降级。 +6. 工作包 G:后端 runtime story action / state / session 真实实现已迁入 `modules/rpg-runtime-story`,旧热点已退化为兼容导出。 +7. 工作包 H:仓储、shared contract 与定向测试基建已落到新域命名与分文件结构。 + +结论: + +**第二批并行的主链改造基本已经完成,本轮确认到的真实遗漏主要集中在“工程收口状态”而不是“功能未落地”。** + +## 4. 本轮确认的遗漏项 + +## 4.1 工作包 E 相关测试文件仍处于未解决合流状态 + +复核时发现: + +1. `src/hooks/story/npcEncounterActions.test.ts` 的文件内容已经切到 `../rpg-runtime-story/rpgRuntimeStoryGateway` 新路径。 +2. 但 Git 索引仍把该文件标记为 `UU`,说明这份并行工作没有真正完成收口。 + +这会导致: + +1. 工作包 E 虽然逻辑上已完成迁移,但工程状态仍不能算完全闭合。 +2. 后续继续判断第二批并行是否完成时,会被未解决合流状态误判为仍未完成。 + +## 4.2 后端总测试文件仍保留同一轮并行改动的未解决合流状态 + +复核时发现: + +1. `server-node/src/app.test.ts` 仍处于 `UU` 状态。 +2. 该文件中的两侧改动并不冲突: + - 一侧是把微信登录测试的 cookie 解析统一改成 `readCookieValue(...)` + - 另一侧是补充 custom world agent SSE enriched session 回归测试 +3. 当前文件内容已经同时包含两侧结果,但索引没有完成正式收口。 +4. 合并后还残留了一个未被使用的 `accessCookie` 中间变量,属于典型的合流尾巴。 + +这同样属于: + +**不是功能缺失,而是并行工作未彻底收口。** + +## 4.3 技术文档索引未完整登记本轮工作包文档 + +复核时发现: + +1. `docs/technical/README.md` 已登记 A、D、E、F、G、H 的进度文档。 +2. 但缺少工作包 B 与工作包 C 的进度文档入口。 +3. 这会让后续按文档索引回溯第二批并行工作时出现“文档已存在但目录索引缺失”的信息断层。 + +这属于文档收口遗漏,应当在本轮一并补齐。 + +## 5. 本轮补齐动作 + +本轮只补以下缺口: + +1. 清理 `server-node/src/app.test.ts` 中合流后残留的未使用变量,保留两侧都应保留的测试结果。 +2. 将 `src/hooks/story/npcEncounterActions.test.ts` 作为工作包 E 已确认的正确内容进行正式收口。 +3. 将 `server-node/src/app.test.ts` 作为同轮并行改动的正确合并结果进行正式收口。 +4. 更新 `docs/technical/README.md`,补齐工作包 B、工作包 C 与本复核文档入口。 + +## 6. 本轮明确没有做的事 + +为了避免过度开发,本轮没有继续做以下事情: + +1. 没有继续拆 UI 组件。 +2. 没有继续清理旧兼容 façade。 +3. 没有额外重构任何 route / service / repository。 +4. 没有调整任何前端交互界面设计。 +5. 没有顺手处理与本次 RPG 并行重构无直接关系的其他噪音文件。 + +## 7. 复核后状态结论 + +在本轮补齐之后,可以对第一批与第二批并行工作给出如下结论: + +1. 第一批并行没有发现新增遗漏。 +2. 第二批并行的主链功能改造已经基本齐备。 +3. 本轮确认并补齐的遗漏主要是测试合流收口与文档索引缺口。 +4. 当前不需要继续扩大改造范围,后续可以按执行计划进入第三批统一收口。 diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md new file mode 100644 index 00000000..d405719f --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md @@ -0,0 +1,191 @@ +# RPG 进入游戏与运行时链路重构第三批收口记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **第三批收口**,严格遵守以下边界: + +1. 把第二批工作包 B 到 H 已完成的新域 façade 真正接回主链。 +2. 对照执行计划逐项检查“开始游戏 / 继续游戏 / 角色选择 / 冒险 runtime / 存档恢复”是否仍残留旧命名真实依赖。 +3. 只清理确认属于 RPG 主链的新域反向依赖与未接线仓储,不扩到创作链、UI 设计或玩法语义。 + +## 2. 本次完成的第三批收口 + +## 2.1 前端运行态主链不再反向依赖旧 `GameShell` 热点 + +本轮把以下真实实现从旧 `game-shell` 命名下摘回 `rpg-runtime-shell`: + +1. `src/components/rpg-runtime-shell/RpgRuntimeCanvasStage.tsx` +2. `src/components/rpg-runtime-shell/rpgRuntimeLoaders.tsx` +3. `src/components/rpg-runtime-shell/useRpgSceneTransitionModel.ts` + +同时完成以下主链接线: + +1. `src/components/rpg-runtime-shell/RpgRuntimeShell.tsx` 直接使用 `RpgRuntimeCanvasStage` +2. `src/components/rpg-runtime-shell/RpgRuntimeOverlayHost.tsx` 直接使用 `rpgRuntimeLoaders` +3. `src/components/rpg-runtime-shell/useRpgRuntimeShellViewModel.ts` 直接使用 `useRpgSceneTransitionModel` +4. `src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx` 直接从 `rpg-runtime-shell` 读取 `PanelLoadingFallback` + +当前结果: + +1. 运行态主链真实实现已经不再依赖旧 `GameShellCanvasStage`、`GameShellLoaders`、`useSceneTransitionModel`。 +2. 旧 `game-shell` 文件只保留兼容 re-export,不再作为 RPG 运行态真实落点。 + +## 2.2 前端入口域主链不再反向依赖旧平台展示 helper + +本轮把以下入口域通用展示能力收回 `rpg-entry`: + +1. `src/components/rpg-entry/RpgEntryBrandLogo.tsx` +2. `src/components/rpg-entry/rpgEntryWorldPresentation.ts` +3. `src/components/rpg-entry/RpgEntryCreationTypeModal.tsx` + +并完成以下主链接线: + +1. `RpgEntryHomeView.tsx` 改为直接使用 `RpgEntryBrandLogo` 与 `rpgEntryWorldPresentation` +2. `RpgEntryWorldDetailView.tsx` 改为直接使用 `rpgEntryWorldPresentation` +3. `RpgEntryFlowShellImpl.tsx` 改为直接使用 `RpgEntryCreationTypeModal` + +当前结果: + +1. `rpg-entry` 真实实现不再依赖旧 `PlatformBrandLogo`、`platformWorldPresentation`、`PlatformCreationTypeModal`。 +2. 旧 `game-shell` 对应文件只保留兼容导出。 + +## 2.3 工作包 H 新仓储已真正接回后端 RPG 主链 + +本轮把工作包 H 中已建立但尚未接回主链的新仓储正式注入 `AppContext`: + +1. `rpgProfileDashboardRepository` +2. `rpgBrowseHistoryRepository` +3. `rpgSaveArchiveRepository` +4. `rpgRuntimeSnapshotRepository` + +并完成以下主链接线: + +1. `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts` + - 资料看板、钱包流水、游玩统计走 `rpgProfileDashboardRepository` + - 浏览历史走 `rpgBrowseHistoryRepository` + - 设置读写走 `rpgProfileDashboardRepository` +2. `server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts` + - snapshot 读写删除走 `rpgRuntimeSnapshotRepository` + - save archive 列表与恢复走 `rpgSaveArchiveRepository` +3. `server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts` + - runtime story 状态读取与动作结算走 `rpgRuntimeSnapshotRepository` + +当前结果: + +1. `rpgProfile`、`rpgEntrySave`、`rpgRuntimeStory` 主链已经不再直接把大 `runtimeRepository` 当作唯一注入边界。 +2. 工作包 H 的新仓储不再停留在“命名骨架已存在但主链未接线”的状态。 + +## 2.4 新域 shared contract 进一步脱离旧 `story.ts` façade + +本轮继续把 RPG 新主链中的 shared contract 直接切到分文件: + +1. `server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts` + - 改为直接使用 `rpgRuntimeStoryState.ts` +2. `server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts` + - 改为直接使用 `rpgRuntimeChat.ts`、`rpgRuntimeQuestAssist.ts` +3. `server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.ts` + - 改为直接使用 `rpgRuntimeStoryAction.ts` + - snapshot 类型改为使用 `RpgRuntimeSavedSnapshot` +4. `server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts` + - runtime story request/response 类型改为直接使用 `rpgRuntimeStoryState.ts` + - snapshot 仓储端口改为使用 `RpgRuntimeSnapshotRepositoryPort` + +当前结果: + +1. RPG 新域主链已经显著减少对旧 `packages/shared/src/contracts/story.ts` façade 的反向依赖。 +2. `story.ts` 继续保留兼容职责,但不再是第三批已收口主链的真实首选入口。 + +## 2.5 补齐世界库主链接线遗漏 + +代码级复核后,本轮额外补齐了第三批范围内一个真实遗漏:世界库主链虽然已经有 `rpg-entry` 路由和 `RpgWorldLibraryRepository`,但此前并未完整接回主链。 + +本次补齐包括: + +1. `server-node/src/context.ts` 与 `server-node/src/server.ts` + - 正式注入 `rpgWorldLibraryRepository` +2. `server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts` + - 作品库/广场/详情/发布/下架/删除等读写改为直接走 `rpgWorldLibraryRepository` + - `rpgWorldProfileRepository` 继续保留给 Agent 发布链与发布服务使用 +3. `src/services/rpg-entry/rpgEntryLibraryClient.ts` + - 改为直接承接 `/api/runtime/custom-world-library` 与 `/api/runtime/custom-world-gallery` 请求 + - 不再反向依赖旧 `storageService.ts` 兼容层 +4. `src/services/rpg-entry/rpgEntryLibraryClient.test.ts` + - 补齐入口世界库 client 的定向回归 + +当前结果: + +1. “平台首页 / 世界详情 / 开始游戏”所依赖的世界库链路,现在已经和 save/profile 一样真正回到 `rpg-entry` + `rpg-entry repository` 主链。 +2. 第三批范围内不再残留“新命名已建好,但真实主链仍穿旧兼容层/旧仓储”的世界库遗漏。 + +## 3. 老代码物理删除补充 + +根据后续收口要求,本轮在主链稳定后继续删除旧兼容层,不再让 RPG 入口与运行态链路通过旧脚本名兜底。 + +已删除的旧前端入口包括: + +1. `src/components/AdventurePanel.tsx` +2. `src/components/game-shell/*` +3. `src/components/game-shell/rpg-creation-flow/*` +4. `src/hooks/useGameFlow.ts` +5. `src/hooks/useGamePersistence.ts` +6. `src/hooks/useGameShellRuntime.ts` +7. `src/hooks/useStoryGeneration.ts` +8. `src/services/runtimeStoryService.ts` +9. `src/services/storageService.ts` + +已删除的旧后端与共享契约入口包括: + +1. `server-node/src/routes/runtimeRoutes.ts` +2. `server-node/src/modules/story/runtimeSession.ts` +3. `server-node/src/modules/story/storyActionService.ts` +4. `server-node/src/modules/story/storyActionRoutes.ts` +5. `packages/shared/src/contracts/story.ts` + +同步完成的主链迁移包括: + +1. `RpgEntryFlowShellImpl.tsx` 直接使用 `src/components/rpg-entry/useRpgCreation*` hooks,不再反向依赖 `game-shell/rpg-creation-flow`。 +2. runtime story client 测试迁到 `src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts`。 +3. profile / world library 路由测试迁到 `src/services/rpg-entry/rpgEntryClients.routing.test.ts`。 +4. 冒险面板测试迁到 `src/components/rpg-runtime-panels/RpgAdventurePanel*.test.tsx`。 +5. `AdventurePanelOverlays` 子模块改名并迁到 `src/components/rpg-runtime-panels/RpgAdventurePanelOverlays.tsx`。 +6. 后端与前端 shared contract import 已切到 `rpgRuntimeChat.ts`、`rpgRuntimeQuestAssist.ts`、`rpgRuntimeStoryAction.ts`、`rpgRuntimeStoryState.ts`。 + +本轮仍然刻意没有继续扩大到以下内容: + +1. 没有重命名历史审计、旧 PRD、旧技术方案中作为历史记录出现的旧文件名。 +2. 没有继续拆分 `runtimeRepository.ts` 剩余实现。 +3. 没有改 UI 布局、按钮位置、tab 组织、弹层方式或任何用户可见交互设计。 +4. 没有清理与本执行计划无关的创作链旧文件;只删除 RPG 入口 / 运行态链路已经不再依赖的旧脚本。 + +## 4. 本轮检查后确认的未扩范围 + +为了避免过度开发,本轮明确没有继续扩到以下内容: + +1. 没有继续改 UI 布局、按钮位置、tab 组织、弹层方式或任何用户可见交互设计。 +2. 没有继续拆分 `runtimeRepository.ts` 剩余实现,也没有扩大到更多仓储接口重命名。 +3. 没有顺手清理历史文档里的旧文件名引用,只更新本次执行收口文档。 + +## 5. 第三批遗漏复核结论 + +对照执行计划中的第三批要求,本轮确认如下: + +1. **把 B 到 H 的 façade 接回主链**:已完成 + - 前端 `rpg-entry`、`rpg-runtime-shell`、`rpg-runtime-panels` + - 后端 `rpg-profile`、`rpg-entry-save`、`rpg-runtime-story` +2. **联调继续游戏 / 开始游戏 / 角色选择 / 冒险 runtime / 存档恢复**:本轮已把这些链路上的主注入边界和真实落点接回新域 +3. **清理旧命名与兼容导出**:已完成主链级清理,并按本次补充要求物理删除旧兼容脚本 + +当前仍保留但不计为遗漏的部分: + +1. 历史审计、旧 PRD、旧技术方案中仍会提到旧文件名,这是历史记录,不代表运行时代码依赖。 +2. `src/components/rpg-runtime-panels/RpgAdventurePanel.tsx` 仍保持原交互结构,只做脚本路径与命名收口。 + +## 6. 结论 + +在“不改 UI、不改玩法、不扩到创作链执行方案”的前提下,本轮已经完成 RPG 执行计划中的第三批主链收口: + +1. 新域真实实现已经接回前后端主链。 +2. RPG 主链对旧 `GameShell` / `runtimeRoutes` / `story.ts` façade 的真实依赖已经删除。 +3. 复核中发现并补齐了世界库主链接线遗漏后,当前没有再发现属于本次第三批范围、且必须继续补做的遗漏项。 diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..c67cd443 --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md @@ -0,0 +1,101 @@ +# RPG 进入游戏与运行时链路重构工作包 A 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 A:RPG 命名规范与目录骨架**,严格遵守以下边界: + +1. 先建立 RPG 进入游戏链的新目录与命名落点。 +2. 先补 façade、barrel、path 常量和按域仓储入口,不提前迁移主流程逻辑。 +3. 不修改现有前端交互界面设计,不提前实现工作包 B 到 H 的真实拆分。 + +## 2. 本次已落地内容 + +## 2.1 前端目录骨架 + +已新增以下前端目录与兼容 façade: + +1. `src/components/rpg-entry/` +2. `src/components/rpg-runtime-shell/` +3. `src/components/rpg-runtime-panels/` +4. `src/hooks/rpg-session/` +5. `src/hooks/rpg-runtime-story/` +6. `src/services/rpg-entry/` +7. `src/services/rpg-runtime/` + +当前策略: + +1. `RpgEntryFlowShell` 先桥接当前真实存在的 `src/components/game-shell/rpg-creation-flow/RpgCreationShell.tsx`,不再依赖已删除的旧 `PreGameSelectionFlow.tsx`。 +2. `RpgEntryHomeView`、`RpgEntryWorldDetailView`、`RpgEntryCharacterSelectView` 继续桥接旧平台首页、世界详情和选角视图。 +3. `RpgRuntimeShell`、`RpgRuntimeStageRouter`、`RpgRuntimePanelRouter`、`RpgAdventurePanel`、`RpgRuntimeOverlayHost` 继续桥接旧 `GameShell*` 与 `AdventurePanel` 组件。 +4. `useRpgSessionBootstrap`、`useRpgSessionPersistence`、`useRpgRuntimeSession` 继续桥接 `useGameFlow`、`useGamePersistence`、`useGameShellRuntime`。 +5. `useRpgRuntimeStory`、`useRpgRuntimeStoryController`、`useRpgRuntimeStoryFlow`、`useRpgRuntimeInteractionFlow`、`useRpgRuntimeNpcInteraction`、`rpgRuntimeStoryGateway` 继续桥接旧 story runtime hooks 与 gateway。 + +## 2.2 前端 service/client 骨架 + +已新增以下 service/client 落点: + +1. `src/services/rpg-entry/rpgEntryLibraryClient.ts` +2. `src/services/rpg-entry/rpgProfileClient.ts` +3. `src/services/rpg-runtime/rpgSnapshotClient.ts` +4. `src/services/rpg-runtime/rpgRuntimeStoryClient.ts` +5. `src/services/rpg-runtime/rpgRuntimeChatClient.ts` + +当前策略: + +1. `rpgEntryLibraryClient` 继续桥接 `storageService.ts` 中的世界库、世界广场与发布相关接口。 +2. `rpgProfileClient` 继续桥接资料看板、浏览历史、设置与继续游戏归档相关接口。 +3. `rpgSnapshotClient` 继续桥接快照读写接口。 +4. `rpgRuntimeStoryClient` 继续桥接 `/api/runtime/story` 的旧 client。 +5. `rpgRuntimeChatClient` 继续桥接 `aiService.ts` 中的角色聊天、NPC 对话与招募对话接口。 + +## 2.3 后端目录骨架 + +已新增以下后端目录与 façade: + +1. `server-node/src/routes/rpg-entry/` +2. `server-node/src/routes/rpg-profile/` +3. `server-node/src/routes/rpg-runtime/` +4. `server-node/src/modules/rpg-runtime-story/` +5. `server-node/src/repositories/rpg-entry/` +6. `server-node/src/repositories/rpg-profile/` +7. `server-node/src/repositories/rpg-runtime/` + +当前策略: + +1. `createRpgProfileRoutes()`、`createRpgEntrySaveRoutes()`、`createRpgWorldLibraryRoutes()`、`createRpgRuntimeAiAssistRoutes()` 当前只提供空路由骨架与稳定 path 常量。 +2. `createRpgRuntimeStoryRoutes()` 继续桥接旧 `createStoryActionRoutes()`,确保 runtime story 路由已经有新命名落点。 +3. `RpgRuntimeStoryActionService`、`RpgRuntimeSessionLoader`、`RpgRuntimeOptionCompiler`、`RpgRuntimeSnapshotSync` 继续桥接旧 `storyActionService.ts` 与 `runtimeSession.ts`。 +4. `RpgRuntimeSnapshotRepository`、`RpgSaveArchiveRepository`、`RpgProfileDashboardRepository`、`RpgWorldLibraryRepository` 先以委托 runtimeRepository 的方式建立按域命名仓储入口。 + +## 3. 本次没有做的事 + +以下内容仍保持原状,留给后续工作包: + +1. 没有改 `GameShellMainContent.tsx`、`GameShellRuntime.tsx`、`AdventurePanel.tsx` 的内部实现。 +2. 没有拆 `useGameFlow.ts`、`useGamePersistence.ts`、`useGameShellRuntime.ts` 的真实逻辑。 +3. 没有拆 `useStoryGeneration.ts`、`npcEncounterActions.ts`、`runtimeStoryCoordinator.ts` 的内部职责。 +4. 没有改 `server-node/src/app.ts`、`server-node/src/routes/runtimeRoutes.ts`、`server-node/src/modules/story/storyActionService.ts`、`server-node/src/modules/story/runtimeSession.ts` 的真实挂载与内部逻辑。 +5. 没有补 shared contract 新文件,本轮执行方案的工作包 A 范围未把共享契约骨架列为必做项,因此保持到工作包 H 统一收口。 +6. 没有修改任何前端交互界面设计。 + +## 4. 验证与现状说明 + +本轮已执行: + +1. `npm run check:encoding` + +验证结果: + +1. 编码检查通过。 +2. 全量 `npm run typecheck` 当前未通过,但失败项主要来自工作树中已存在的并行修改与历史类型问题。 +3. 本轮已修正新 façade 中对已删除旧入口 `PreGameSelectionFlow.tsx` 的失效导入,当前 `RpgEntryFlowShell` 已改为桥接真实存在的 `rpg-creation-flow` 入口。 + +## 5. 对后续工作包的直接收益 + +1. 工作包 B 可以直接把平台入口与选角真实实现落到 `src/components/rpg-entry/`,而不必再次讨论命名与目录。 +2. 工作包 C 可以直接把 session/bootstrap/persistence 的调用方迁到 `src/hooks/rpg-session/` 与 `src/services/rpg-runtime/`。 +3. 工作包 D 可以直接让运行态壳层与面板消费 `rpg-runtime-shell`、`rpg-runtime-panels` 新入口。 +4. 工作包 E 可以直接把 story runtime 与 NPC 交互迁到 `src/hooks/rpg-runtime-story/` 和 `src/services/rpg-runtime/`。 +5. 工作包 F、G、H 可以直接基于 `server-node/src/routes/rpg-*`、`server-node/src/modules/rpg-runtime-story/`、`server-node/src/repositories/rpg-*/` 继续做真实迁移,而不用重新搭第一层命名骨架。 diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..97a7b689 --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md @@ -0,0 +1,108 @@ +# RPG 进入游戏与运行时链路重构工作包 B 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 B:前端 RPG 入口壳层拆分**,严格遵守以下边界: + +1. 只改前端入口链,不改后端接口语义。 +2. 只处理平台首页、世界详情、继续游戏、进入世界、选角相关入口壳层与入口 hooks。 +3. 不拆运行态冒险面板,不修改任何前端交互界面设计。 + +## 2. 本次已完成内容 + +## 2.1 `rpg-entry` 已成为真实入口目录 + +以下文件现在承载真实实现,不再只是 façade: + +1. `src/components/rpg-entry/RpgEntryFlowShell.tsx` +2. `src/components/rpg-entry/RpgEntryFlowShellImpl.tsx` +3. `src/components/rpg-entry/RpgEntryHomeView.tsx` +4. `src/components/rpg-entry/RpgEntryWorldDetailView.tsx` +5. `src/components/rpg-entry/RpgEntryCharacterSelectView.tsx` +6. `src/components/rpg-entry/rpgEntryTypes.ts` +7. `src/components/rpg-entry/rpgEntryShared.ts` + +这意味着当前 RPG 平台入口、详情页和选角页的真实物理落点已经从旧 `game-shell` 命名转入 `rpg-entry` 域,满足执行方案里“平台首页/详情页/选角能落到 `rpgEntry` 目录”的验收要求。 + +## 2.2 入口相关 hooks 已补齐到 `rpg-entry` + +本轮新增或迁入以下入口域 hooks: + +1. `src/components/rpg-entry/useRpgEntryBootstrap.ts` +2. `src/components/rpg-entry/useRpgEntryLibraryDetail.ts` +3. `src/components/rpg-entry/useRpgEntryNavigation.ts` +4. `src/components/rpg-entry/useRpgEntrySaveResume.ts` +5. `src/components/rpg-entry/useRpgEntryCharacterSelect.ts` + +其中: + +1. `useRpgEntryBootstrap` 负责平台 works / library / gallery / history / save / dashboard 拉取与继续游戏恢复入口。 +2. `useRpgEntryLibraryDetail` 负责详情页打开、作品详情读取、继续创作入口、发布/下架/删除动作。 +3. `useRpgEntryNavigation` 收口入口阶段跳转,避免壳层里继续散落匿名 `setSelectionStage(...)`。 +4. `useRpgEntrySaveResume` 明确“继续游戏”动作入口。 +5. `useRpgEntryCharacterSelect` 补齐选角页回退与确认动作的入口域命名。 + +## 2.3 旧 `game-shell` / `rpg-creation-flow` 路径已降级为兼容层 + +以下旧文件已改为兼容 re-export,不再持有真实实现: + +1. `src/components/game-shell/PlatformHomeView.tsx` +2. `src/components/game-shell/PlatformWorldDetailView.tsx` +3. `src/components/game-shell/CharacterSelectionFlow.tsx` +4. `src/components/game-shell/rpg-creation-flow/RpgCreationShell.tsx` +5. `src/components/game-shell/rpg-creation-flow/RpgCreationShellImpl.tsx` +6. `src/components/game-shell/rpg-creation-flow/rpgCreationFlowTypes.ts` +7. `src/components/game-shell/rpg-creation-flow/rpgCreationFlowShared.ts` +8. `src/components/game-shell/rpg-creation-flow/useRpgCreationPlatformBootstrap.ts` +9. `src/components/game-shell/rpg-creation-flow/useRpgCreationDetailNavigation.ts` + +兼容策略如下: + +1. 旧命名继续导出,避免并行工作包与现有测试断开。 +2. 真实实现统一回落到 `rpg-entry`。 +3. 本轮不清理 legacy façade,只让它们退化成稳定桥接层。 + +## 2.4 主链调用方已接回 `rpg-entry` + +本轮已把主入口相关调用改为直接消费 `rpg-entry`: + +1. `src/components/game-shell/GameShellMainContent.tsx` +2. `src/components/game-shell/useGameShellViewModel.ts` +3. `src/components/rpg-entry/index.ts` + +当前结果: + +1. 主阶段路由器 lazy import 已直接走 `RpgEntryFlowShell` 与 `RpgEntryCharacterSelectView`。 +2. `SelectionStage` 类型已从 `rpg-entry` 暴露,不再依赖旧 `rpg-creation-flow` 作为主命名根。 +3. 旧路径仍可用,但已经不再是主链真实入口。 + +## 3. 对照执行方案的完成判断 + +工作包 B 本轮已完成以下计划项: + +1. 已把平台首页/详情页/继续游戏/进入世界从旧 `PreGameSelectionFlow` 所属旧命名体系中收进 `rpg-entry` 域。 +2. 已让 `rpg-entry` 成为真实实现目录,而不是只保留 façade。 +3. 已补齐入口阶段相关新 hooks。 +4. 已让旧路径退化为兼容桥接层。 +5. 没有修改任何前端交互界面设计。 + +当前仍刻意保留的边界: + +1. 没有拆运行态冒险面板,这属于工作包 D。 +2. 没有改 session/bootstrap/persistence 主逻辑,这属于工作包 C。 +3. 没有改后端接口语义与 route/service 边界,这属于工作包 F/G/H。 +4. 没有提前清理所有 legacy re-export,本轮以稳定主链为先。 + +## 4. 验证与遗漏核查 + +本轮需要重点核查的遗漏项已经逐项确认: + +1. `RpgEntryFlowShell` 不再桥接旧实现,已直接使用 `rpg-entry` 目录下的真实壳层。 +2. `PlatformHomeView`、`PlatformWorldDetailView`、`CharacterSelectionFlow` 的旧路径已不再持有真实实现。 +3. `GameShellMainContent` 已直接 lazy import `rpg-entry` 新入口。 +4. `SelectionStage` 已从 `rpg-entry` 作为主出口暴露。 +5. 复核时补齐了一个第二批收口遗漏:`RpgEntryFlowShellImpl`、`useRpgEntryBootstrap`、`useRpgEntryLibraryDetail` 这些 `rpg-entry` 真实实现已改为直接消费 `rpg-entry` 新域 client,不再反向依赖旧 `storageService.ts` 作为主链入口。 + +本轮未执行全量 `typecheck`,原因是工作树存在并行改动与未解决冲突文件;但已把本工作包范围内最容易遗漏的主链接线点和兼容层路径全部补齐。 diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_C_PROGRESS_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_C_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..f77bb823 --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_C_PROGRESS_2026-04-21.md @@ -0,0 +1,115 @@ +# RPG 进入游戏与运行时链路重构工作包 C 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 C:前端 session / bootstrap / persistence 拆分**,严格遵守以下边界: + +1. 把 `useGameFlow.ts`、`useGamePersistence.ts`、`useGameShellRuntime.ts` 的真实实现收进 `rpg-session` 域。 +2. 把 snapshot / save archive 相关 client 从 `storageService.ts` 抽到 `src/services/rpg-runtime/`、`src/services/rpg-entry/`。 +3. 旧文件只保留兼容导出,不再继续承载主实现。 +4. 不修改 AdventurePanel UI,不修改后端 story 动作语义,不修改任何前端交互界面设计。 + +## 2. 本次已落地内容 + +## 2.1 `rpg-session` 主实现已承接 session 链 + +本轮已把以下真实实现迁入新域目录: + +1. `src/hooks/rpg-session/useRpgSessionBootstrap.ts` +2. `src/hooks/rpg-session/useRpgSessionPersistence.ts` +3. `src/hooks/rpg-session/useRpgRuntimeSession.ts` +4. `src/hooks/rpg-session/rpgSessionTypes.ts` + +落地结果: + +1. `useRpgSessionBootstrap` 现在直接承载世界选择、角色确认、新开局 `GameState` 初始化逻辑。 +2. `useRpgSessionPersistence` 现在直接承载自动存档、继续游戏恢复、远端快照拉取与 runtime story 恢复刷新。 +3. `useRpgRuntimeSession` 现在直接组合 bootstrap / persistence / story / combat / companion 等链路,成为主运行态装配入口。 + +## 2.2 旧 `useGame*` 文件已退化为兼容 façade + +以下旧文件已不再承载主实现,只保留兼容导出: + +1. `src/hooks/useGameFlow.ts` +2. `src/hooks/useGamePersistence.ts` +3. `src/hooks/useGameShellRuntime.ts` + +当前策略: + +1. 旧调用方仍可继续工作,避免影响并行工作包。 +2. 正式主入口已经切到 `useRpgRuntimeSession`。 +3. `BottomTab` 类型已从 `rpg-session` 域提供,避免继续绑在旧 hook 文件上。 + +## 2.3 snapshot / save archive client 已迁到新域 + +本轮已把快照与继续游戏归档的真实请求实现迁入: + +1. `src/services/rpg-runtime/rpgRuntimeRequest.ts` +2. `src/services/rpg-runtime/rpgSnapshotClient.ts` +3. `src/services/rpg-entry/rpgProfileClient.ts` + +落地结果: + +1. `rpgSnapshotClient` 现在直接承载 `/api/runtime/save/snapshot` 的读取、写入、删除。 +2. `rpgProfileClient` 现在直接承载设置、个人资料、浏览历史、继续游戏归档相关请求。 +3. `storageService.ts` 已退化为兼容转发层,不再继续作为 snapshot / save archive 主实现落点。 + +## 2.4 主入口与直接类型依赖已切换 + +本轮已更新以下直接调用点: + +1. `src/App.tsx` 改为直接使用 `useRpgRuntimeSession` +2. `src/components/game-shell/types.ts` +3. `src/components/game-shell/GameShellMainContent.tsx` +4. `src/components/game-shell/GameShellStoryPanels.tsx` + +当前状态: + +1. 运行态主入口已经不再直接依赖 `useGameShellRuntime`。 +2. `GameShell` 相关组件仍保持 UI 与结构不变,只调整了 session 域导入路径。 + +## 2.5 测试补齐 + +本轮补齐或切换了以下定向测试: + +1. `src/hooks/runtimeAuthGuards.test.tsx` +2. `src/hooks/useGameFlow.customWorld.test.tsx` +3. `src/services/rpg-entry/rpgProfileClient.test.ts` +4. `src/services/rpg-runtime/rpgSnapshotClient.test.ts` + +目的: + +1. 覆盖 `rpg-session` 新 hook 的远端鉴权守卫行为。 +2. 覆盖自定义世界进入世界后的 bootstrap 初始化结果。 +3. 覆盖迁入新域后的 browse history / save archive / snapshot 路由请求。 + +## 3. 本次没有做的事 + +以下内容仍保持原状,留给后续工作包: + +1. 没有拆 `AdventurePanel.tsx`、`GameShellRuntime.tsx`、`GameShellMainContent.tsx` 的内部 UI 组织。 +2. 没有拆 `useStoryGeneration.ts`、`npcEncounterActions.ts`、`runtimeStoryCoordinator.ts` 的 runtime story 内部职责。 +3. 没有修改后端 `storyActionService.ts`、`runtimeSession.ts` 或任何路由组织。 +4. 没有修改平台入口/世界详情/选角页面的视觉结构、按钮位置、tab 组织和独立面板交互方式。 + +## 4. 验证与检查 + +本轮应执行并记录: + +1. `npm run check:encoding` +2. `npx vitest run src/hooks/runtimeAuthGuards.test.tsx src/hooks/useGameFlow.customWorld.test.tsx src/services/rpg-entry/rpgProfileClient.test.ts src/services/rpg-runtime/rpgSnapshotClient.test.ts src/services/storageService.test.ts` + +重点核查点: + +1. 主入口是否已经切到 `useRpgRuntimeSession`。 +2. `useGameFlow.ts`、`useGamePersistence.ts`、`useGameShellRuntime.ts` 是否只剩兼容导出。 +3. `storageService.ts` 是否已不再承载 snapshot / save archive 的真实实现。 +4. 是否没有改动任何前端界面结构与交互表现。 + +## 5. 对后续工作包的直接收益 + +1. 工作包 B / D 可以继续消费 `rpg-session` 新域入口,而不必再从旧 `useGame*` 文件接主逻辑。 +2. 工作包 E 可以只关注 runtime story 链,不必再同时承担 session / persistence 主实现迁移。 +3. 后续清理旧命名时,可以直接删除 `useGame*` 兼容层,而不会再触发大规模逻辑回迁。 diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..df42ce99 --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md @@ -0,0 +1,130 @@ +# RPG 进入游戏与运行时链路重构工作包 D 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 D:前端运行态 shell 与面板拆分**,严格遵守以下边界: + +1. 把 `GameShellRuntime.tsx`、`GameShellMainContent.tsx`、`GameShellStoryPanels.tsx`、`AdventurePanel.tsx` 的真实实现迁入 `rpg-runtime-shell` 与 `rpg-runtime-panels`。 +2. 旧 `GameShell*` 与 `AdventurePanel` 保留兼容桥接,不在这一轮硬删旧路径。 +3. 不改平台入口链、不改 runtime story 后端协议、不改任何前端交互界面设计。 +4. 冒险主面板只做最小必要的 section 拆分,不额外扩散成更多与工作包 D 无关的重构。 + +## 2. 本次已落地内容 + +## 2.1 RPG 运行态 shell 已承接真实实现 + +以下文件已从 façade 升级为真实实现: + +1. `src/components/rpg-runtime-shell/RpgRuntimeShell.tsx` +2. `src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx` +3. `src/components/rpg-runtime-shell/RpgRuntimeOverlayHost.tsx` +4. `src/components/rpg-runtime-shell/useRpgRuntimeShellViewModel.ts` +5. `src/components/rpg-runtime-shell/useRpgRuntimeOverlayState.ts` +6. `src/components/rpg-runtime-shell/types.ts` + +本轮完成的真实迁移包括: + +1. `App.tsx` 主入口已经改为直接挂载 `RpgRuntimeShell`,不再把旧 `GameShellRuntime` 当作真实主入口。 +2. `RpgRuntimeShell` 已承接运行态总外壳、画布舞台、主阶段路由、overlay host 装配。 +3. `RpgRuntimeStageRouter` 已承接平台入口 / 角色选择 / 冒险运行态三阶段切换。 +4. `RpgRuntimeOverlayHost` 已承接角色面板浮层、背包浮层、冒险实体详情、营地、地图、角色聊天与 NPC 交互弹层。 +5. `useRpgRuntimeShellViewModel` 已承接运行态 overlay 状态、过场可见态、统计数据和 scene transition choice 包装。 +6. `useRpgRuntimeOverlayState` 已把旧 `useGameShellViewModel` 的壳层状态迁入 RPG 域命名。 + +## 2.2 RPG 主面板路由与冒险主面板已承接真实实现 + +以下文件已从 façade 升级为真实实现: + +1. `src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx` +2. `src/components/rpg-runtime-panels/RpgAdventurePanel.tsx` + +本轮完成的真实迁移包括: + +1. `RpgRuntimePanelRouter` 已承接冒险 / 角色 / 背包三主标签切换。 +2. `RpgAdventurePanel` 已成为真实冒险主面板实现,不再只是桥接旧 `AdventurePanel`。 +3. 冒险主面板按执行计划要求显式拆成了三个主 section: + - story section:剧情展示区与对话流 + - choice section:按钮区、快捷入口、NPC 输入框 + - overlay section:任务/设置/统计/奖励等独立面板挂载 +4. 拆分后保持了原有 UI 结构、按钮位置、浮层方式和交互顺序不变。 +5. 同时修正了冒险面板测试里要求隐藏的说明文本,避免迁移后把 `detailText` 重新暴露到 UI 中。 + +## 2.3 旧热点文件已降级为兼容桥接层 + +以下旧热点文件已不再承载真实实现,只保留兼容入口: + +1. `src/components/game-shell/GameShellRuntime.tsx` +2. `src/components/game-shell/GameShellMainContent.tsx` +3. `src/components/game-shell/GameShellStoryPanels.tsx` +4. `src/components/game-shell/GameShellOverlays.tsx` +5. `src/components/AdventurePanel.tsx` +6. `src/components/game-shell/useGameShellRuntimeViewModel.ts` +7. `src/components/game-shell/useGameShellViewModel.ts` +8. `src/components/game-shell/types.ts` + +当前策略: + +1. 旧 `GameShell*` 路径继续可被现有调用与测试引用。 +2. 旧 hook / type 文件继续对外提供兼容别名,避免并行工作流马上失效。 +3. 真实运行态主链已经切到 `rpg-runtime-shell` 与 `rpg-runtime-panels`,旧热点不再继续扩大职责。 + +## 2.4 为兼容迁移补齐的新类型出口 + +为了避免本轮迁移造成旧桥接和 barrel 断裂,本轮额外补齐了以下类型出口: + +1. `RpgRuntimeShellProps` +2. `RpgRuntimeShellViewModelResult` +3. `RpgEntryHomeViewProps` +4. `RpgEntryWorldDetailViewProps` + +这些改动只用于保证工作包 D 新目录可以作为真实调用入口和兼容桥的稳定目标,不属于额外功能开发。 + +## 3. 本次刻意未做的事 + +以下内容明确保持到其他工作包,不在本轮越界处理: + +1. 没有改平台入口链编排;`rpg-entry` 的真实拆分仍属于工作包 B。 +2. 没有改 session/bootstrap/persistence 语义;这部分仍属于工作包 C。 +3. 没有改 runtime story hooks、NPC 交互主链与后端协议;这部分仍属于工作包 E 及后续后端工作包。 +4. 没有重做 AdventurePanel 内部更细粒度的卡片/子面板组件树,只做执行计划明确要求的三段 section 拆分。 +5. 没有删除旧 `GameShell*` 文件,只把它们降级为兼容桥,避免影响并行工作流。 +6. 没有调整任何用户可见的布局、按钮位置、tab 组织或弹窗/独立面板出现方式。 + +## 4. 验证结果 + +本轮已完成以下验证: + +1. `npm run check:encoding` +2. `npx vitest run src/components/AdventurePanel.test.tsx src/components/AdventurePanel.npcChat.test.tsx src/components/game-shell/useGameShellRuntimeViewModel.test.ts src/components/CustomWorldEntityEditorModal.test.tsx` +3. 针对本工作包改动路径执行 `tsc` 定向筛查 + +验证结果: + +1. 编码检查通过。 +2. 上述 4 组定向测试全部通过。 +3. 针对本轮改动路径的类型筛查无新增报错。 +4. 全量 `tsc` 仍然存在其他工作流文件的并行类型噪音,但未命中本轮工作包 D 改动文件。 + +## 5. 对工作包 D 完成度的复核 + +对照执行计划中的工作包 D 目标,本轮已完成: + +1. 运行态 shell 的真实迁移。 +2. 主阶段路由器的真实迁移。 +3. 主面板路由器的真实迁移。 +4. 冒险主面板的真实迁移。 +5. 冒险主面板按 section 显式分段。 +6. 旧热点文件降级为兼容桥。 + +仍刻意保留、但不属于遗漏的部分: + +1. 更细粒度的 AdventurePanel 子组件继续保留在单文件内部。 +2. 旧文件兼容导出尚未清理。 +3. 运行态 story hooks / gateway 仍在工作包 E 路径中继续演进。 +4. 本轮复核已补齐一个收口遗漏:`rpg-runtime-shell` / `rpg-runtime-panels` 真实实现所依赖的 story UI 类型,现已直接从 `hooks/rpg-runtime-story` 导入,不再反向依赖旧 `useStoryGeneration.ts` 兼容入口。 + +结论: + +**工作包 D 在“不改 UI、不改后端协议、禁止过度开发”的前提下已经完整落地,当前没有遗漏的必做项。** diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..82eb4b36 --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md @@ -0,0 +1,112 @@ +# RPG 进入游戏与运行时链路重构工作包 E 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 E:前端 runtime story 与 NPC 交互链拆分**,严格遵守以下边界: + +1. 把前端 runtime story 主链真实迁到 `src/hooks/rpg-runtime-story/` 与 `src/services/rpg-runtime/`。 +2. 把 `useStoryGeneration.ts`、`useStoryRuntimeController.ts`、`useStoryFlowCoordinator.ts`、`useStoryGoalSessionCoordinator.ts`、`useStoryInteractionCoordinator.ts`、`npcEncounterActions.ts`、`runtimeStoryCoordinator.ts`、`runtimeStoryService.ts` 的正式实现从旧命名入口迁出。 +3. 保留旧 `story/*` 与 `runtimeStoryService.ts` 兼容导出,避免误伤其他并行工作包。 +4. 不改任何前端交互界面设计,不改后端动作语义,不新增玩法。 + +## 2. 本次已落地内容 + +## 2.1 RPG runtime story 主链真实迁移 + +已把以下真实实现迁入 RPG 域目录: + +1. `src/hooks/rpg-runtime-story/useRpgRuntimeStory.ts` +2. `src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts` +3. `src/hooks/rpg-runtime-story/useRpgRuntimeStoryFlow.ts` +4. `src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.ts` +5. `src/hooks/rpg-runtime-story/useRpgRuntimeInteractionFlow.ts` +6. `src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts` +7. `src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts` + +本轮完成后: + +1. `useRpgRuntimeStory` 已成为前端 runtime story 顶层装配入口,不再只是 façade。 +2. `useRpgRuntimeStoryController` 负责 story 状态、AI 请求与提交动作。 +3. `useRpgRuntimeStoryFlow` 负责 option 展示、交互流和 story/session 状态流收口。 +4. `useRpgRuntimeStoryState` 负责 reset、hydrate、地图跳转与 quest UI 收口。 +5. `useRpgRuntimeInteractionFlow` 负责宝箱、背包、NPC、story choice 正式分发。 +6. `useRpgRuntimeNpcInteraction` 负责 NPC 对话、待接委托、战斗后续对话重开与服务端 NPC 动作派发。 +7. `rpgRuntimeStoryGateway` 负责 option catalog 拉取、继续游戏恢复与服务端 runtime choice 结算。 + +## 2.2 runtime story client 真实迁移 + +已把 `/api/runtime/story` 的真实 client 实现迁入: + +1. `src/services/rpg-runtime/rpgRuntimeStoryClient.ts` + +本轮完成后: + +1. `rpgRuntimeStoryClient` 不再桥接旧 `runtimeStoryService.ts`,而是成为真实请求实现。 +2. `runtimeStoryService.ts` 降级为兼容导出层。 +3. `getRpgRuntimeStoryState`、`resolveRpgRuntimeStoryAction`、`resolveRpgRuntimeStoryMoment`、`isRpgRuntimeServerFunctionId`、`shouldUseRpgRuntimeServerOptions` 已成为新域主能力。 + +## 2.3 主调用链已接回 RPG 域 + +本轮已把以下主链与 helper 入口切到 RPG 域实现: + +1. `src/hooks/rpg-session/useRpgRuntimeSession.ts` 改为直接使用 `useRpgRuntimeStory` +2. `src/hooks/story/storyRequestCoordinator.ts` 改为消费 `loadRpgRuntimeOptionCatalog` 与 `shouldUseRpgRuntimeServerOptions` +3. `src/hooks/story/choiceActions.ts` 改为消费 `isRpgRuntimeServerFunctionId` +4. `src/hooks/story/storyChoiceRuntime.ts` 改为消费 `resolveRpgRuntimeChoice` +5. `src/hooks/story/inventoryActions.ts` 改为消费 `resolveRpgRuntimeChoice` +6. `src/hooks/story/npcInteraction.ts` 改为消费 `resolveRpgRuntimeChoice` +7. `src/hooks/useTreasureFlow.ts` 改为消费 `resolveRpgRuntimeChoice` + +这意味着工作包 E 范围内的前端 runtime story 正式结算主链,已经不再依赖旧 `runtimeStoryService.ts` 与旧 `runtimeStoryCoordinator.ts` 的真实实现。 + +## 2.4 兼容层处理 + +以下旧文件现已降级为兼容导出,不再承载正式实现: + +1. `src/hooks/useStoryGeneration.ts` +2. `src/hooks/story/useStoryRuntimeController.ts` +3. `src/hooks/story/useStoryFlowCoordinator.ts` +4. `src/hooks/story/useStoryGoalSessionCoordinator.ts` +5. `src/hooks/story/useStoryInteractionCoordinator.ts` +6. `src/hooks/story/npcEncounterActions.ts` +7. `src/hooks/story/runtimeStoryCoordinator.ts` +8. `src/services/runtimeStoryService.ts` + +保留这些兼容层的原因: + +1. 避免一次性改动所有 UI 与测试引用,降低并行工作冲突。 +2. 让工作包 D、C 仍能通过旧导入继续编译,再逐步切到新域命名。 +3. 明确“主实现已迁移、旧入口只兼容”的工程状态,避免后续继续扩大旧热点文件。 +4. 复核时已补齐一批主链收口点:`RpgRuntimeStageRouter`、`RpgRuntimeOverlayHost`、`RpgRuntimePanelRouter`、`RpgAdventurePanel` 及其直连组件的 story UI 类型导入,现已直接消费 `hooks/rpg-runtime-story` 新域出口,旧 `useStoryGeneration.ts` 仅保留兼容角色。 + +## 3. 本次没有做的事 + +以下内容仍保持原状,留给其他工作包: + +1. 没有修改 `AdventurePanel.tsx`、`GameShellMainContent.tsx`、`GameShellStoryPanels.tsx` 的 UI 结构。 +2. 没有重做任何对话区、奖励区、输入区、overlay 的交互形式。 +3. 没有修改后端 runtime story route / service / compiler 语义。 +4. 没有清理所有旧 UI 组件对 `useStoryGeneration.ts` 的类型导入,本轮只保证旧入口已退化为兼容层。 +5. 没有删除旧兼容文件,避免误伤其他并行中的工作包。 + +## 4. 验证结果 + +本轮已执行: + +1. `npm run check:encoding` +2. `npx vitest run src/services/runtimeStoryService.test.ts src/hooks/story/runtimeStoryCoordinator.test.ts src/hooks/story/storyRequestCoordinator.test.ts src/hooks/story/choiceActions.test.ts src/hooks/story/storyChoiceRuntime.test.ts src/hooks/story/npcEncounterActions.test.ts` + +验证结果: + +1. 编码检查通过。 +2. 工作包 E 直接相关的 6 个测试文件、44 条测试全部通过。 +3. 运行时 option catalog、runtime choice、NPC 交互、待接委托、背包动作与本地/服务端分流逻辑均完成回归。 +4. 复核补跑 `src/services/rpg-entry/rpgProfileClient.test.ts`、`src/services/rpg-runtime/rpgSnapshotClient.test.ts`、`src/services/runtimeStoryService.test.ts`、`src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx` 共 33 条定向测试,全部通过。 + +## 5. 对后续工作包的直接收益 + +1. 工作包 D 后续如果继续拆 runtime panel 与 adventure panel,可以直接消费 `useRpgRuntimeStory` 与 `rpg-runtime-story` 域类型,不必再穿旧 `story` 目录。 +2. 工作包 C 与 session/persistence 链路已经可以直接对接 `rpgRuntimeStoryGateway` 的继续游戏恢复能力。 +3. 后续如果要清理旧 `useStoryGeneration.ts`、`runtimeStoryService.ts` 等旧命名入口,已经具备“新主链真实可用”的前提,不再是 façade 空壳状态。 diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..ffd38ed4 --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md @@ -0,0 +1,121 @@ +# RPG 进入游戏与运行时链路重构工作包 F 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 F:后端 route 边界拆分**,严格遵守以下边界: + +1. 只调整后端路由组织、挂载边界与兼容 façade。 +2. 不重写下游 `service` / `repository` 业务语义。 +3. 不修改任何前端交互界面设计,不额外推进工作包 G/H。 + +## 2. 本次已落地内容 + +## 2.1 `app.ts` 显式挂载 RPG 域路由 + +已把 `server-node/src/app.ts` 中的 RPG 相关入口改成显式按域挂载: + +1. `rpgProfile`:资料看板、浏览历史、设置 +2. `rpgEntrySave`:快照读写、继续游戏归档列表与恢复 +3. `rpgWorldLibrary`:作品库、作品广场、works 列表 +4. `rpgRuntimeStory`:`/api/runtime/story` 状态读取与动作结算 +5. `rpgRuntimeAiAssist`:runtime story 之外的 AI 辅助接口 + +当前策略: + +1. 使用 `scopeToPrefixes(...)` 只匹配各自域前缀,避免新路由误拦截无关 `/api` 请求。 +2. 所有线上接口路径保持不变,仍兼容现有前端与测试调用。 +3. `routeVersion` 对新域挂载统一标记为 `2026-04-21`,与本轮路由重构窗口对齐。 + +## 2.2 新 `rpg-*` 路由从骨架升级为真实实现 + +已把以下骨架路由补成真实入口: + +1. `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts` +2. `server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts` +3. `server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts` +4. `server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts` +5. `server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts` + +本轮真实迁入的新边界包括: + +1. `rpgProfileRoutes.ts` + - `/api/runtime/profile/dashboard` + - `/api/runtime/profile/wallet-ledger` + - `/api/runtime/profile/play-stats` + - `/api/runtime/profile/browse-history` + - 兼容路径 `/api/profile/...` + - `/api/runtime/settings` +2. `rpgEntrySaveRoutes.ts` + - `/api/runtime/save/snapshot` + - `/api/runtime/profile/save-archives` + - 兼容路径 `/api/profile/save-archives` +3. `rpgWorldLibraryRoutes.ts` + - `/api/runtime/custom-world-gallery` + - `/api/runtime/custom-world/works` + - `/api/runtime/custom-world-library` + - publish / unpublish / soft delete 等库操作 +4. `rpgRuntimeStoryRoutes.ts` + - `/api/runtime/story/actions/resolve` + - `/api/runtime/story/state/:sessionId` + - `/api/runtime/story/state/resolve` +5. `rpgRuntimeAiAssistRoutes.ts` + - runtime story 之外的 LLM proxy、cover/scene 资产、custom world profile 生成、角色/NPC chat、runtime item、quest 生成、`/api/ws/health` + +## 2.3 旧大路由退化为兼容 façade + +已把旧入口降级为兼容层: + +1. `server-node/src/modules/story/storyActionRoutes.ts` + - 不再自己承载 schema 与 handler + - 直接桥接到 `createRpgRuntimeStoryRoutes(context)` +2. `server-node/src/routes/runtimeRoutes.ts` + - 不再继续承载 profile / save / world library / runtime ai assist / runtime story + - 当前只保留旧 `customWorldAgent` 挂载与兼容 `ws/health` + +这样处理后: + +1. `runtimeRoutes.ts` 不再是 RPG 主链真实入口。 +2. `storyActionRoutes.ts` 不再是 runtime story 主链真实实现。 +3. 后续工作包 G/H 可以基于新 `rpg-*` 路由继续细化,而不用再穿透旧大文件。 + +## 3. 本次没有做的事 + +以下内容仍保持原状,不属于工作包 F 本轮范围: + +1. 没有拆 `storyActionService.ts`、`runtimeSession.ts` 的内部职责。 +2. 没有继续改 `npcInteractionService.ts`、`questStoryActionService.ts` 的业务实现。 +3. 没有推进仓储、共享契约的进一步物理拆分。 +4. 没有改变任何前端页面结构、交互路径、面板形式或 UI 文案。 + +## 4. 验证结果 + +本次已执行: + +1. `npm run check:encoding` +2. `node --test --test-concurrency=1 --import tsx "src/routes/rpgRouteBoundaries.test.ts"` + +验证结果: + +1. 编码检查通过。 +2. 新增定向测试 `server-node/src/routes/rpgRouteBoundaries.test.ts` 4 项全部通过。 +3. 已覆盖以下工作包 F 验收重点: + - `rpgProfile` 新路径与 legacy 路径兼容 + - `rpgEntrySave` 快照/归档路由可用 + - `rpgWorldLibrary` / `gallery` / `works` 新边界可用 + - `rpgRuntimeStory` 新边界与旧 façade 兼容可用 + +同时确认: + +1. 全量 `npx tsc -p server-node/tsconfig.json --noEmit` 仍被仓库中既有跨模块类型问题阻塞。 +2. 这些错误覆盖 auth、inventory、custom world、scene image 等多个既有热点,与本次工作包 F 新增路由边界并非同一问题面。 + +## 5. 对执行计划的对齐结论 + +对照执行计划中的工作包 F: + +1. `app.ts` 已能一眼看出 `rpgProfile`、`rpgEntry`、`rpgRuntimeStory`、`rpgRuntimeAiAssist` 的挂载边界。 +2. `runtimeRoutes.ts` 已退化为兼容入口,不再承接 RPG 主链的大杂糅职责。 +3. `storyActionRoutes.ts` 已退化为兼容 façade,真实 runtime story 路由落点转入 `server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts`。 +4. 本轮没有越过工作包 F 去重写下游 service 语义,满足“禁止过度开发”的约束。 diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..fd25b44a --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md @@ -0,0 +1,96 @@ +# RPG 进入游戏与运行时链路重构工作包 G 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 G:后端 runtime session / action service 拆分**,严格遵守以下边界: + +1. 把 `storyActionService.ts` 与 `runtimeSession.ts` 的真实实现迁入 `server-node/src/modules/rpg-runtime-story/` 新域目录。 +2. 把 runtime action 主链依赖的 session 原语、option 编译、snapshot sync、story state 读取按职责落到新文件。 +3. 旧 `server-node/src/modules/story/` 热点文件只保留兼容导出,不再承载真实实现。 +4. 不改前端入口与 UI,不改路由协议语义,不做仓储拆分。 + +## 2. 本次已落地内容 + +## 2.1 `runtimeSession.ts` 真实实现已迁入 `rpg-runtime-story` + +本轮已把旧 `server-node/src/modules/story/runtimeSession.ts` 的真实实现迁入: + +1. `server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.ts` +2. `server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionLoader.ts` +3. `server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionPrimitives.ts` +4. `server-node/src/modules/rpg-runtime-story/RpgRuntimeOptionCompiler.ts` +5. `server-node/src/modules/rpg-runtime-story/RpgRuntimeSnapshotSync.ts` +6. `server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryPresentationCompiler.ts` + +落地结果: + +1. `loadRuntimeSession(...)`、`buildAvailableOptions(...)`、`buildRuntimeViewModel(...)`、`syncRawGameState(...)`、`replaceRuntimeSessionRawGameState(...)` 已有新域真实落点。 +2. `appendStoryHistory(...)`、`getEncounterNpcState(...)`、`setEncounterNpcState(...)`、`MAX_TASK5_COMPANIONS`、`TASK6_DEFERRED_FUNCTION_IDS` 等运行时原语已通过 `RpgRuntimeSessionPrimitives.ts` 对外输出。 +3. 旧 `server-node/src/modules/story/runtimeSession.ts` 已退化为兼容层,不再承载主实现。 + +## 2.2 `storyActionService.ts` 真实实现已迁入 `rpg-runtime-story` + +本轮已把旧 `server-node/src/modules/story/storyActionService.ts` 的真实实现迁入: + +1. `server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts` +2. `server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionService.ts` +3. `server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryStateService.ts` + +落地结果: + +1. `resolveRuntimeStoryAction(...)` 已从新域动作服务入口导出。 +2. `getRuntimeStoryState(...)` 已从新域状态服务入口导出。 +3. 旧 `server-node/src/modules/story/storyActionService.ts` 已退化为兼容转发层。 + +## 2.3 runtime action 主链下游依赖已切到新域 + +本轮已把以下直接依赖 `runtimeSession.ts` 的后端模块切到 `rpg-runtime-story` 新域入口: + +1. `server-node/src/modules/combat/combatResolutionService.ts` +2. `server-node/src/modules/npc/npcInteractionService.ts` +3. `server-node/src/modules/inventory/inventoryStoryActionService.ts` +4. `server-node/src/modules/inventory/npcInventoryStoryActionService.ts` +5. `server-node/src/modules/quest/questRuntimeSignalService.ts` +6. `server-node/src/modules/quest/questStoryActionService.ts` +7. `server-node/src/modules/runtime-item/treasureStoryActionService.ts` +8. `server-node/src/modules/story/storyActionRoutes.ts` +9. `server-node/src/modules/story/runtimeSession.test.ts` + +当前状态: + +1. runtime action 执行链已经直接消费 `rpg-runtime-story` 域入口,不再把旧 `modules/story/` 视为真实主实现。 +2. `storyActionRoutes.ts` 已直接从新域读取动作服务与状态服务。 +3. `runtimeSession.test.ts` 已直接验证新域编译链与 legacy currentStory 展示投影。 + +## 3. 本次没有做的事 + +以下内容仍保持原状,留给后续工作包或后续阶段: + +1. 没有修改任何前端入口、前端运行态 UI、前端交互设计。 +2. 没有拆 `server-node/src/routes/runtimeRoutes.ts`、`server-node/src/app.ts` 的路由挂载组织,这属于工作包 F。 +3. 没有拆 `runtimeRepository.ts`、`runtimeSnapshotHydration.ts` 与 shared contract,这属于工作包 H。 +4. 没有把 `RpgRuntimeStoryActionDomain.ts` 再继续切成更细颗粒度的真实实现文件;本轮只做到工作包 G 要求的“目录化拆开 + 旧热点降级”。 +5. 没有改动任何动作语义、返回协议或 LLM 编排行为。 + +## 4. 验证与检查 + +本轮已执行: + +1. `npm run check:encoding` +2. `npx tsx --test server-node/src/modules/story/runtimeSession.test.ts server-node/src/modules/story/storyActionRoutes.test.ts` +3. `npm --prefix server-node run build` + +验证结果: + +1. 编码检查通过。 +2. 与 runtime session / runtime story action 直接相关的后端定向测试 `20` 项全部通过。 +3. `server-node` 构建通过。 +4. 旧 `server-node/src/modules/story/runtimeSession.ts` 与 `server-node/src/modules/story/storyActionService.ts` 已只剩兼容导出,没有残留真实主实现。 + +## 5. 对后续工作的直接收益 + +1. 工作包 F 后续继续做 route 边界拆分时,可以直接把 runtime story 路由稳定挂到 `rpg-runtime-story` 新域入口。 +2. 工作包 H 后续拆仓储、shared contract 和测试基建时,可以围绕 `rpg-runtime-story` 新目录继续收口,而不必再穿透旧热点文件。 +3. 后续如果继续细拆 runtime story 主链,可以在新域内部继续物理拆分,而不会重新把真实实现塞回 `modules/story/`。 diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..cc8f51fb --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md @@ -0,0 +1,90 @@ +# RPG 进入游戏与运行时链路重构工作包 H 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 H:仓储、契约与测试基建**,严格遵守以下边界: + +1. 只补仓储、shared contract、fixture 与测试基建。 +2. 不修改前端 UI,不改页面交互设计。 +3. 不重写 route 层与 runtime story 主流程逻辑。 + +## 2. 本次已落地内容 + +## 2.1 仓储按 RPG 域补齐独立入口 + +本次补齐并收口了工作包 H 目标中的按域仓储入口: + +1. `server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.ts` +2. `server-node/src/repositories/rpg-entry/RpgSaveArchiveRepository.ts` +3. `server-node/src/repositories/rpg-entry/RpgWorldLibraryRepository.ts` +4. `server-node/src/repositories/rpg-profile/RpgProfileDashboardRepository.ts` +5. `server-node/src/repositories/rpg-profile/RpgBrowseHistoryRepository.ts` + +本轮策略仍保持最小侵入: + +1. 新仓储继续委托 `runtimeRepository.ts` 提供真实读写。 +2. `RpgProfileDashboardRepository` 只保留资料看板、设置、钱包、游玩统计职责。 +3. 浏览历史读写从资料仓储中抽离到 `RpgBrowseHistoryRepository`,与执行方案的目标拆分保持一致。 + +## 2.2 shared runtime contract 按领域拆分并保留兼容 façade + +已把原 `packages/shared/src/contracts/story.ts` 中的 RPG runtime shared contract 拆分为: + +1. `packages/shared/src/contracts/rpgRuntimeStoryAction.ts` +2. `packages/shared/src/contracts/rpgRuntimeStoryState.ts` +3. `packages/shared/src/contracts/rpgRuntimeChat.ts` +4. `packages/shared/src/contracts/rpgRuntimeQuestAssist.ts` + +兼容策略: + +1. `story.ts` 退化为 façade,只做分文件 re-export。 +2. 现有前后端调用方仍可继续从 `contracts/story` 取用类型与常量,不要求本轮同步迁移所有 import。 +3. runtime story 主链契约与 chat / quest assist / runtime item 辅助契约已经具备独立演进落点。 + +## 2.3 测试基建补齐 + +本次补充并核对了工作包 H 范围内的测试: + +1. `server-node/src/modules/runtime/runtimeSnapshotHydration.test.ts` + 用于覆盖 snapshot 归一化、恢复继续游戏时的默认值修复与旧存档兼容。 +2. `server-node/src/repositories/rpg-profile/RpgProfileRepositories.test.ts` + 用于确认资料看板仓储与浏览历史仓储的职责边界已经分离。 +3. `server-node/src/repositories/rpg-entry/RpgEntryRepositories.test.ts` + 用于确认 continue game 归档仓储与作品库仓储已经独立命名并保持职责边界。 +4. `server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.test.ts` + 用于确认 snapshot 读写职责已经有独立仓储入口。 +5. `packages/shared/src/contracts/rpgRuntimeContracts.test.ts` + 用于确认 `story.ts` façade 在拆分后仍保持旧入口兼容。 + +## 3. 本次没有做的事 + +以下内容仍保持原状,留给其他工作包或第三批统一收口: + +1. 没有把前后端所有 `contracts/story` import 全量改写到新分文件,避免与并行工作包产生无谓冲突。 +2. 没有改 `server-node/src/routes/runtimeRoutes.ts`、`server-node/src/modules/story/storyActionService.ts`、`server-node/src/modules/story/runtimeSession.ts` 的真实逻辑。 +3. 没有改前端 continue game、角色选择、冒险运行态的界面与交互。 + +## 4. 验证结果 + +本轮执行并通过: + +1. `npm run check:encoding` +2. `npx vitest run packages/shared/src/contracts/rpgRuntimeContracts.test.ts` +3. `node --test --test-concurrency=1 --import tsx src/modules/runtime/runtimeSnapshotHydration.test.ts src/repositories/rpg-profile/RpgProfileRepositories.test.ts src/repositories/rpg-entry/RpgEntryRepositories.test.ts src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.test.ts` + +说明: + +1. 工作树当前存在其他并行修改与冲突文件,因此没有把与工作包 H 无关的全量问题一并处理。 +2. 本轮验证只覆盖工作包 H 自身改动与其直接依赖,避免过度开发。 + +## 5. 与执行方案的对照结论 + +对照 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中工作包 H 的目标,本轮已完成: + +1. snapshot / profile / library / browse history 仓储独立命名入口补齐。 +2. `story.ts` shared contract 已拆为 runtime story action、runtime story state、runtime chat、runtime quest assist 四个独立文件。 +3. runtime story / snapshot / continue game 相关测试已补齐到可直接回归的最小闭环。 + +当前未额外扩张到主流程迁移、route 改写、UI 调整,符合“禁止过度开发”的执行要求。 diff --git a/packages/shared/src/contracts/customWorldAgent.ts b/packages/shared/src/contracts/customWorldAgent.ts index 4be16d88..e5d115e0 100644 --- a/packages/shared/src/contracts/customWorldAgent.ts +++ b/packages/shared/src/contracts/customWorldAgent.ts @@ -1,538 +1,12 @@ -export type CustomWorldWorkStatus = 'draft' | 'published'; -export type CustomWorldWorkSource = 'agent_session' | 'published_profile'; +/** + * 兼容出口: + * 当前仓库仍有大量旧 customWorld 命名导入,这个文件继续作为过渡层保留。 + * 工作包 H 完成后,真实类型定义已经迁移到 rpg* 契约文件中;这里仅聚合旧命名分文件。 + */ -export interface WorldPromiseValue { - hook: string; - differentiator: string; - desiredExperience: string; -} - -export interface PlayerFantasyValue { - playerRole: string; - corePursuit: string; - fearOfLoss: string; -} - -export interface ThemeBoundaryValue { - toneKeywords: string[]; - aestheticDirectives: string[]; - forbiddenDirectives: string[]; -} - -export interface PlayerEntryPointValue { - openingIdentity: string; - openingProblem: string; - entryMotivation: string; -} - -export interface CoreConflictValue { - surfaceConflicts: string[]; - hiddenCrisis: string; - firstTouchedConflict: string; -} - -export interface KeyRelationshipValue { - pairs: string; - relationshipType: string; - secretOrCost: string; -} - -export interface HiddenLineValue { - hiddenTruths: string[]; - misdirectionHints: string[]; - revealPacing: string; -} - -export interface IconicElementValue { - iconicMotifs: string[]; - institutionsOrArtifacts: string[]; - hardRules: string[]; -} - -export interface EightAnchorContent { - worldPromise: WorldPromiseValue | null; - playerFantasy: PlayerFantasyValue | null; - themeBoundary: ThemeBoundaryValue | null; - playerEntryPoint: PlayerEntryPointValue | null; - coreConflict: CoreConflictValue | null; - keyRelationships: KeyRelationshipValue[]; - hiddenLines: HiddenLineValue | null; - iconicElements: IconicElementValue | null; -} - -export interface CustomWorldWorkSummary { - workId: string; - sourceType: CustomWorldWorkSource; - status: CustomWorldWorkStatus; - title: string; - subtitle: string; - summary: string; - coverImageSrc?: string | null; - coverRenderMode?: 'image' | 'scene_with_roles'; - coverCharacterImageSrcs?: string[]; - updatedAt: string; - publishedAt?: string | null; - stage?: string | null; - stageLabel?: string | null; - playableNpcCount: number; - landmarkCount: number; - roleVisualReadyCount?: number; - roleAnimationReadyCount?: number; - roleAssetSummaryLabel?: string | null; - sessionId?: string | null; - profileId?: string | null; - canResume: boolean; - canEnterWorld: boolean; -} - -export interface CreatorIntentReadiness { - isReady: boolean; - completedKeys: string[]; - missingKeys: string[]; -} - -export interface CustomWorldPendingClarification { - id: string; - label: string; - question: string; - targetKey: - | 'world_hook' - | 'player_premise' - | 'theme_and_tone' - | 'core_conflict' - | 'relationship_seed' - | 'iconic_element'; - priority: number; - answer?: string; -} - -export type CustomWorldAgentStage = - | 'collecting_intent' - | 'clarifying' - | 'foundation_review' - | 'object_refining' - | 'visual_refining' - | 'long_tail_review' - | 'ready_to_publish' - | 'published' - | 'error'; - -export type CustomWorldAgentMessageRole = 'user' | 'assistant' | 'system'; - -export type CustomWorldAgentMessageKind = - | 'chat' - | 'clarification' - | 'summary' - | 'checkpoint' - | 'warning' - | 'action_result'; - -export interface CustomWorldAgentMessage { - id: string; - role: CustomWorldAgentMessageRole; - kind: CustomWorldAgentMessageKind; - text: string; - createdAt: string; - relatedOperationId?: string | null; -} - -export type CustomWorldDraftCardKind = - | 'world' - | 'camp' - | 'faction' - | 'character' - | 'landmark' - | 'thread' - | 'chapter' - | 'scene_chapter' - | 'carrier' - | 'sidequest_seed'; - -export type CustomWorldDraftCardStatus = - | 'suggested' - | 'confirmed' - | 'locked' - | 'warning'; - -export interface CustomWorldDraftCardSummary { - id: string; - kind: CustomWorldDraftCardKind; - title: string; - subtitle: string; - summary: string; - status: CustomWorldDraftCardStatus; - linkedIds: string[]; - warningCount: number; - assetStatus?: CustomWorldRoleAssetStatus | null; - assetStatusLabel?: string | null; -} - -export interface CustomWorldDraftCardDetailSection { - id: string; - label: string; - value: string; -} - -export interface CustomWorldFoundationDraftFaction { - id: string; - name: string; - title?: string; - subtitle?: string; - publicGoal: string; - relatedConflict: string; - tension?: string; - playerRelation: string; - summary: string; -} - -export interface CustomWorldFoundationDraftCharacter { - id: string; - name: string; - title: string; - role: string; - publicIdentity: string; - publicMask?: string; - currentPressure: string; - hiddenHook?: string; - relationToPlayer: string; - threadIds: string[]; - summary: string; - skills?: Array<{ - id: string; - name: string; - actionPreviewConfig?: Record | null; - }>; - imageSrc?: string | null; - generatedVisualAssetId?: string | null; - generatedAnimationSetId?: string | null; - animationMap?: Record | null; -} - -export interface CustomWorldFoundationDraftLandmark { - id: string; - name: string; - description?: string; - purpose: string; - mood: string; - importance: string; - secret?: string; - dangerLevel?: string; - imageSrc?: string | null; - characterIds: string[]; - threadIds: string[]; - summary: string; -} - -export interface CustomWorldFoundationDraftThread { - id: string; - title: string; - type: 'main' | 'hidden'; - conflictType?: string; - conflict: string; - stakes?: string; - characterIds: string[]; - landmarkIds: string[]; - summary: string; -} - -export interface CustomWorldFoundationDraftChapter { - id: string; - title: string; - openingEvent: string; - playerGoal: string; - characterIds: string[]; - landmarkIds: string[]; - understandingShift: string; - summary: string; -} - -export interface CustomWorldFoundationDraftCamp { - id: string; - name: string; - description: string; - mood: string; - dangerLevel?: string; - imageSrc?: string | null; - summary: string; -} - -export type CustomWorldSceneActStage = - | 'opening' - | 'expansion' - | 'turning_point' - | 'climax' - | 'aftermath'; - -export type CustomWorldSceneActAdvanceRule = - | 'after_primary_contact' - | 'after_active_step_complete' - | 'after_chapter_resolution'; - -export interface CustomWorldFoundationDraftSceneAct { - id: string; - title: string; - summary: string; - stageCoverage: CustomWorldSceneActStage[]; - backgroundImageSrc?: string | null; - backgroundAssetId?: string | null; - encounterNpcIds: string[]; - primaryNpcId: string; - linkedThreadIds: string[]; - actGoal: string; - transitionHook: string; - advanceRule: CustomWorldSceneActAdvanceRule; -} - -export interface CustomWorldFoundationDraftSceneChapter { - id: string; - sceneId: string; - sceneName: string; - title: string; - summary: string; - linkedThreadIds: string[]; - linkedLandmarkIds: string[]; - acts: CustomWorldFoundationDraftSceneAct[]; -} - -export interface CustomWorldFoundationDraftProfile { - name: string; - subtitle: string; - summary: string; - tone: string; - playerGoal: string; - majorFactions: string[]; - coreConflicts: string[]; - playableNpcs: CustomWorldFoundationDraftCharacter[]; - storyNpcs: CustomWorldFoundationDraftCharacter[]; - landmarks: CustomWorldFoundationDraftLandmark[]; - camp?: CustomWorldFoundationDraftCamp | null; - themePack?: Record | null; - storyGraph?: Record | null; - factions: CustomWorldFoundationDraftFaction[]; - threads: CustomWorldFoundationDraftThread[]; - chapters: CustomWorldFoundationDraftChapter[]; - sceneChapters: CustomWorldFoundationDraftSceneChapter[]; - worldHook: string; - playerPremise: string; - openingSituation: string; - iconicElements: string[]; - sourceAnchorSummary: string; -} - -export interface CustomWorldFoundationDraftResult { - draftProfile: CustomWorldFoundationDraftProfile; - draftCards: CustomWorldDraftCardSummary[]; -} - -export interface CustomWorldDraftCardDetail { - id: string; - kind: CustomWorldDraftCardKind; - title: string; - sections: CustomWorldDraftCardDetailSection[]; - linkedIds: string[]; - locked: false; - editable: boolean; - editableSectionIds: string[]; - warningMessages: string[]; - assetStatus?: CustomWorldRoleAssetStatus | null; - assetStatusLabel?: string | null; -} - -export interface CustomWorldSuggestedAction { - id: string; - type: - | 'request_summary' - | 'draft_foundation' - | 'refine_focus_target' - | 'lock_current_target' - | 'generate_role_assets' - | 'generate_scene_assets' - | 'expand_long_tail' - | 'publish_world'; - label: string; - targetId?: string | null; -} - -export type CustomWorldAssetPriorityTier = 'hero' | 'featured' | 'supporting'; - -export type CustomWorldRoleAssetStatus = - | 'missing' - | 'visual_ready' - | 'animations_ready' - | 'complete'; - -export interface CustomWorldRoleAssetSummary { - roleId: string; - roleName: string; - roleKind: 'playable' | 'story'; - priorityTier: CustomWorldAssetPriorityTier; - portraitPath?: string | null; - generatedVisualAssetId?: string | null; - generatedAnimationSetId?: string | null; - status: CustomWorldRoleAssetStatus; - missingAnimations: string[]; - nextPointCost: number; -} - -export interface CustomWorldSceneAssetSummary { - sceneId: string; - sceneName: string; - actId?: string | null; - actTitle?: string | null; - imageSrc?: string | null; - assetId?: string | null; - status: 'missing' | 'ready'; - nextPointCost: number; -} - -export interface CustomWorldAssetCoverageSummary { - roleAssets: CustomWorldRoleAssetSummary[]; - sceneAssets: CustomWorldSceneAssetSummary[]; - allRoleAssetsReady: boolean; - allSceneAssetsReady: boolean; -} - -export interface CustomWorldAgentSessionSnapshot { - sessionId: string; - currentTurn: number; - anchorContent: EightAnchorContent; - progressPercent: number; - lastAssistantReply: string | null; - stage: CustomWorldAgentStage; - focusCardId: string | null; - creatorIntent: Record | null; - creatorIntentReadiness: CreatorIntentReadiness; - anchorPack: Record | null; - lockState: Record | null; - draftProfile: Record | null; - messages: CustomWorldAgentMessage[]; - draftCards: CustomWorldDraftCardSummary[]; - pendingClarifications: CustomWorldPendingClarification[]; - suggestedActions: CustomWorldSuggestedAction[]; - recommendedReplies: string[]; - qualityFindings: { - id: string; - severity: 'info' | 'warning' | 'blocker'; - code: string; - targetId?: string | null; - message: string; - }[]; - assetCoverage: CustomWorldAssetCoverageSummary; - updatedAt: string; -} - -export type CustomWorldAgentOperationType = - | 'process_message' - | 'lock_cards' - | 'unlock_cards' - | 'regenerate_scope' - | 'draft_foundation' - | 'update_draft_card' - | 'sync_result_profile' - | 'generate_characters' - | 'generate_landmarks' - | 'generate_role_assets' - | 'sync_role_assets' - | 'generate_scene_assets' - | 'sync_scene_assets' - | 'expand_long_tail' - | 'publish_world' - | 'revert_checkpoint'; - -export type CustomWorldAgentOperationStatus = - | 'queued' - | 'running' - | 'completed' - | 'failed'; - -export interface CustomWorldAgentOperationRecord { - operationId: string; - type: CustomWorldAgentOperationType; - status: CustomWorldAgentOperationStatus; - phaseLabel: string; - phaseDetail: string; - progress: number; - error?: string | null; -} - -export interface CreateCustomWorldAgentSessionRequest { - seedText?: string; -} - -export interface CreateCustomWorldAgentSessionResponse { - session: CustomWorldAgentSessionSnapshot; -} - -export interface SendCustomWorldAgentMessageRequest { - clientMessageId: string; - text: string; - quickFillRequested?: boolean; - focusCardId?: string | null; - selectedCardIds?: string[]; -} - -export interface SendCustomWorldAgentMessageResponse { - operation: CustomWorldAgentOperationRecord; -} - -export type CustomWorldAgentActionRequest = - | { action: 'lock_cards'; cardIds: string[] } - | { action: 'unlock_cards'; cardIds: string[] } - | { - action: 'regenerate_scope'; - scope: - | 'focus_card' - | 'long_tail_npcs' - | 'long_tail_landmarks' - | 'sidequest_seeds' - | 'role_assets' - | 'scene_assets'; - targetCardId?: string | null; - } - | { action: 'draft_foundation' } - | { - action: 'update_draft_card'; - cardId: string; - sections: Array<{ - sectionId: string; - value: string; - }>; - } - | { - action: 'sync_result_profile'; - profile: Record; - } - | { - action: 'generate_characters'; - count: number; - promptText?: string | null; - anchorCardIds?: string[]; - } - | { - action: 'generate_landmarks'; - count: number; - promptText?: string | null; - anchorCardIds?: string[]; - } - | { action: 'generate_role_assets'; roleIds: string[] } - | { - action: 'sync_role_assets'; - roleId: string; - portraitPath: string; - generatedVisualAssetId: string; - generatedAnimationSetId?: string | null; - animationMap?: Record | null; - } - | { action: 'publish_world' }; - -export interface CustomWorldAgentActionResponse { - operation: CustomWorldAgentOperationRecord; -} - -export interface GetCustomWorldAgentCardDetailResponse { - card: CustomWorldDraftCardDetail; -} - -export interface ListCustomWorldWorksResponse { - items: CustomWorldWorkSummary[]; -} +export type * from './customWorldAgentAnchors'; +export type * from './customWorldAgentDraft'; +export type * from './customWorldAgentActions'; +export type * from './customWorldAgentSession'; +export type * from './customWorldResultPreview'; +export type * from './customWorldWorkSummary'; diff --git a/packages/shared/src/contracts/customWorldAgentActions.ts b/packages/shared/src/contracts/customWorldAgentActions.ts new file mode 100644 index 00000000..a959bd0a --- /dev/null +++ b/packages/shared/src/contracts/customWorldAgentActions.ts @@ -0,0 +1,14 @@ +/** + * 旧 custom world 动作契约兼容出口。 + * 后续若逐步迁移旧代码,建议直接改用 rpgAgentActions.ts。 + */ + +export type { + RpgAgentActionRequest as CustomWorldAgentActionRequest, + RpgAgentActionResponse as CustomWorldAgentActionResponse, + RpgAgentOperationRecord as CustomWorldAgentOperationRecord, + RpgAgentOperationStatus as CustomWorldAgentOperationStatus, + RpgAgentOperationType as CustomWorldAgentOperationType, + RpgAgentSupportedAction as CustomWorldSupportedAction, + RpgAgentSuggestedAction as CustomWorldSuggestedAction, +} from './rpgAgentActions'; diff --git a/packages/shared/src/contracts/customWorldAgentAnchors.ts b/packages/shared/src/contracts/customWorldAgentAnchors.ts new file mode 100644 index 00000000..67d7793a --- /dev/null +++ b/packages/shared/src/contracts/customWorldAgentAnchors.ts @@ -0,0 +1,16 @@ +/** + * 旧 custom world 八锚点兼容出口。 + * 这里只保留旧命名到 RPG 创作域新契约的映射,便于旧导入渐进迁移。 + */ + +export type { + RpgCreationAnchorContent as EightAnchorContent, + RpgCreationCoreConflictValue as CoreConflictValue, + RpgCreationHiddenLineValue as HiddenLineValue, + RpgCreationIconicElementValue as IconicElementValue, + RpgCreationKeyRelationshipValue as KeyRelationshipValue, + RpgCreationPlayerEntryPointValue as PlayerEntryPointValue, + RpgCreationPlayerFantasyValue as PlayerFantasyValue, + RpgCreationThemeBoundaryValue as ThemeBoundaryValue, + RpgCreationWorldPromiseValue as WorldPromiseValue, +} from './rpgAgentAnchors'; diff --git a/packages/shared/src/contracts/customWorldAgentDraft.ts b/packages/shared/src/contracts/customWorldAgentDraft.ts new file mode 100644 index 00000000..4717fed6 --- /dev/null +++ b/packages/shared/src/contracts/customWorldAgentDraft.ts @@ -0,0 +1,29 @@ +/** + * 旧 custom world 草稿契约兼容出口。 + * 工作包 H 完成后,真实定义已经迁到 rpgAgentDraft.ts,这里只负责旧命名映射。 + */ + +export type { + RpgAgentAssetCoverageSummary as CustomWorldAssetCoverageSummary, + RpgAgentAssetPriorityTier as CustomWorldAssetPriorityTier, + RpgAgentDraftCardDetail as CustomWorldDraftCardDetail, + RpgAgentDraftCardDetailSection as CustomWorldDraftCardDetailSection, + RpgAgentDraftCardKind as CustomWorldDraftCardKind, + RpgAgentDraftCardStatus as CustomWorldDraftCardStatus, + RpgAgentDraftCardSummary as CustomWorldDraftCardSummary, + RpgAgentFoundationDraftCamp as CustomWorldFoundationDraftCamp, + RpgAgentFoundationDraftChapter as CustomWorldFoundationDraftChapter, + RpgAgentFoundationDraftCharacter as CustomWorldFoundationDraftCharacter, + RpgAgentFoundationDraftFaction as CustomWorldFoundationDraftFaction, + RpgAgentFoundationDraftLandmark as CustomWorldFoundationDraftLandmark, + RpgAgentFoundationDraftProfile as CustomWorldFoundationDraftProfile, + RpgAgentFoundationDraftResult as CustomWorldFoundationDraftResult, + RpgAgentFoundationDraftSceneAct as CustomWorldFoundationDraftSceneAct, + RpgAgentFoundationDraftSceneChapter as CustomWorldFoundationDraftSceneChapter, + RpgAgentFoundationDraftThread as CustomWorldFoundationDraftThread, + RpgAgentRoleAssetStatus as CustomWorldRoleAssetStatus, + RpgAgentRoleAssetSummary as CustomWorldRoleAssetSummary, + RpgAgentSceneActAdvanceRule as CustomWorldSceneActAdvanceRule, + RpgAgentSceneActStage as CustomWorldSceneActStage, + RpgAgentSceneAssetSummary as CustomWorldSceneAssetSummary, +} from './rpgAgentDraft'; diff --git a/packages/shared/src/contracts/customWorldAgentSession.ts b/packages/shared/src/contracts/customWorldAgentSession.ts new file mode 100644 index 00000000..8a320a40 --- /dev/null +++ b/packages/shared/src/contracts/customWorldAgentSession.ts @@ -0,0 +1,20 @@ +/** + * 旧 custom world 会话契约兼容出口。 + * 这一层只做命名映射,不再承担 session 真相源结构定义。 + */ + +export type { + CreateRpgAgentSessionRequest as CreateCustomWorldAgentSessionRequest, + CreateRpgAgentSessionResponse as CreateCustomWorldAgentSessionResponse, + GetRpgAgentCardDetailResponse as GetCustomWorldAgentCardDetailResponse, + RpgAgentMessage as CustomWorldAgentMessage, + RpgAgentMessageKind as CustomWorldAgentMessageKind, + RpgAgentMessageRole as CustomWorldAgentMessageRole, + RpgAgentPendingClarification as CustomWorldPendingClarification, + RpgAgentQualityFinding as CustomWorldAgentQualityFinding, + RpgAgentSessionSnapshot as CustomWorldAgentSessionSnapshot, + RpgAgentStage as CustomWorldAgentStage, + RpgCreationIntentReadiness as CreatorIntentReadiness, + SendRpgAgentMessageRequest as SendCustomWorldAgentMessageRequest, + SendRpgAgentMessageResponse as SendCustomWorldAgentMessageResponse, +} from './rpgAgentSession'; diff --git a/packages/shared/src/contracts/customWorldResultPreview.ts b/packages/shared/src/contracts/customWorldResultPreview.ts new file mode 100644 index 00000000..56ea9f3c --- /dev/null +++ b/packages/shared/src/contracts/customWorldResultPreview.ts @@ -0,0 +1,12 @@ +/** + * 旧 custom world 结果页预览兼容出口。 + * 额外单独拆一个 preview 兼容文件,避免预览别名继续堆回 customWorldAgent.ts 聚合层。 + */ + +export type { + RpgCreationPreview as CustomWorldResultPreview, + RpgCreationPreviewBlocker as CustomWorldResultPreviewBlocker, + RpgCreationPreviewEnvelope as CustomWorldResultPreviewEnvelope, + RpgCreationPreviewFinding as CustomWorldResultPreviewFinding, + RpgCreationPreviewSource as CustomWorldResultPreviewSource, +} from './rpgCreationPreview'; diff --git a/packages/shared/src/contracts/customWorldWorkSummary.ts b/packages/shared/src/contracts/customWorldWorkSummary.ts new file mode 100644 index 00000000..62a952f9 --- /dev/null +++ b/packages/shared/src/contracts/customWorldWorkSummary.ts @@ -0,0 +1,11 @@ +/** + * 旧 custom world works 读模型兼容出口。 + * 用于把旧作品列表命名平滑映射到新的 RPG 创作域 works 契约。 + */ + +export type { + ListRpgCreationWorksResponse as ListCustomWorldWorksResponse, + RpgCreationWorkSource as CustomWorldWorkSource, + RpgCreationWorkStatus as CustomWorldWorkStatus, + RpgCreationWorkSummary as CustomWorldWorkSummary, +} from './rpgCreationWorkSummary'; diff --git a/packages/shared/src/contracts/rpgAgentActions.ts b/packages/shared/src/contracts/rpgAgentActions.ts index a22227f1..4a47b28a 100644 --- a/packages/shared/src/contracts/rpgAgentActions.ts +++ b/packages/shared/src/contracts/rpgAgentActions.ts @@ -1,7 +1,120 @@ -export type { - CustomWorldAgentActionRequest as RpgAgentActionRequest, - CustomWorldAgentActionResponse as RpgAgentActionResponse, - CustomWorldAgentOperationStatus as RpgAgentOperationStatus, - CustomWorldAgentOperationType as RpgAgentOperationType, - CustomWorldSuggestedAction as RpgAgentSuggestedAction, -} from './customWorldAgent'; +/** + * RPG Agent 动作与异步操作契约。 + * 这里显式区分“建议动作”和“真实可执行动作”,为后续后端 registry 收口预留接口。 + */ + +export type RpgAgentSuggestedActionType = + | 'request_summary' + | 'draft_foundation' + | 'refine_focus_target' + | 'lock_current_target' + | 'generate_role_assets' + | 'generate_scene_assets' + | 'expand_long_tail' + | 'publish_world'; + +export interface RpgAgentSuggestedAction { + id: string; + type: RpgAgentSuggestedActionType; + label: string; + targetId?: string | null; +} + +export type RpgAgentActionType = + | 'draft_foundation' + | 'update_draft_card' + | 'sync_result_profile' + | 'generate_characters' + | 'generate_landmarks' + | 'generate_role_assets' + | 'sync_role_assets' + | 'generate_scene_assets' + | 'sync_scene_assets' + | 'expand_long_tail' + | 'publish_world' + | 'revert_checkpoint'; + +export type RpgAgentActionCapabilityKey = + | RpgAgentSuggestedActionType + | RpgAgentActionType; + +/** + * 当前先把能力矩阵定义为可选契约。 + * 等工作包 E 的 registry 落地后,后端可以把真实 supportedActions 填充到 session snapshot。 + */ +export interface RpgAgentSupportedAction { + action: RpgAgentActionCapabilityKey; + enabled: boolean; + reason?: string | null; +} + +export type RpgAgentOperationType = RpgAgentActionType | 'process_message'; + +export type RpgAgentOperationStatus = + | 'queued' + | 'running' + | 'completed' + | 'failed'; + +export interface RpgAgentOperationRecord { + operationId: string; + type: RpgAgentOperationType; + status: RpgAgentOperationStatus; + phaseLabel: string; + phaseDetail: string; + progress: number; + error?: string | null; +} + +export type RpgAgentActionRequest = + | { action: 'draft_foundation' } + | { + action: 'update_draft_card'; + cardId: string; + sections: Array<{ + sectionId: string; + value: string; + }>; + } + | { + action: 'sync_result_profile'; + profile: Record; + } + | { + action: 'generate_characters'; + count: number; + promptText?: string | null; + anchorCardIds?: string[]; + } + | { + action: 'generate_landmarks'; + count: number; + promptText?: string | null; + anchorCardIds?: string[]; + } + | { action: 'generate_role_assets'; roleIds: string[] } + | { + action: 'sync_role_assets'; + roleId: string; + portraitPath: string; + generatedVisualAssetId: string; + generatedAnimationSetId?: string | null; + animationMap?: Record | null; + } + | { action: 'generate_scene_assets'; sceneIds: string[] } + | { + action: 'sync_scene_assets'; + sceneId: string; + sceneKind: 'camp' | 'landmark'; + imageSrc: string; + generatedSceneAssetId: string; + generatedScenePrompt?: string | null; + generatedSceneModel?: string | null; + } + | { action: 'expand_long_tail' } + | { action: 'publish_world' } + | { action: 'revert_checkpoint'; checkpointId: string }; + +export interface RpgAgentActionResponse { + operation: RpgAgentOperationRecord; +} diff --git a/packages/shared/src/contracts/rpgAgentAnchors.ts b/packages/shared/src/contracts/rpgAgentAnchors.ts index 53a0ddcf..9f4f3e41 100644 --- a/packages/shared/src/contracts/rpgAgentAnchors.ts +++ b/packages/shared/src/contracts/rpgAgentAnchors.ts @@ -1,11 +1,63 @@ -export type { - EightAnchorContent as RpgCreationAnchorContent, - CoreConflictValue as RpgCreationCoreConflictValue, - HiddenLineValue as RpgCreationHiddenLineValue, - IconicElementValue as RpgCreationIconicElementValue, - KeyRelationshipValue as RpgCreationKeyRelationshipValue, - PlayerEntryPointValue as RpgCreationPlayerEntryPointValue, - PlayerFantasyValue as RpgCreationPlayerFantasyValue, - ThemeBoundaryValue as RpgCreationThemeBoundaryValue, - WorldPromiseValue as RpgCreationWorldPromiseValue, -} from './customWorldAgent'; +/** + * RPG 创作八锚点契约。 + * 这一层只描述“创作意图采集态”的结构,不混入 session 或结果页字段。 + */ + +export interface RpgCreationWorldPromiseValue { + hook: string; + differentiator: string; + desiredExperience: string; +} + +export interface RpgCreationPlayerFantasyValue { + playerRole: string; + corePursuit: string; + fearOfLoss: string; +} + +export interface RpgCreationThemeBoundaryValue { + toneKeywords: string[]; + aestheticDirectives: string[]; + forbiddenDirectives: string[]; +} + +export interface RpgCreationPlayerEntryPointValue { + openingIdentity: string; + openingProblem: string; + entryMotivation: string; +} + +export interface RpgCreationCoreConflictValue { + surfaceConflicts: string[]; + hiddenCrisis: string; + firstTouchedConflict: string; +} + +export interface RpgCreationKeyRelationshipValue { + pairs: string; + relationshipType: string; + secretOrCost: string; +} + +export interface RpgCreationHiddenLineValue { + hiddenTruths: string[]; + misdirectionHints: string[]; + revealPacing: string; +} + +export interface RpgCreationIconicElementValue { + iconicMotifs: string[]; + institutionsOrArtifacts: string[]; + hardRules: string[]; +} + +export interface RpgCreationAnchorContent { + worldPromise: RpgCreationWorldPromiseValue | null; + playerFantasy: RpgCreationPlayerFantasyValue | null; + themeBoundary: RpgCreationThemeBoundaryValue | null; + playerEntryPoint: RpgCreationPlayerEntryPointValue | null; + coreConflict: RpgCreationCoreConflictValue | null; + keyRelationships: RpgCreationKeyRelationshipValue[]; + hiddenLines: RpgCreationHiddenLineValue | null; + iconicElements: RpgCreationIconicElementValue | null; +} diff --git a/packages/shared/src/contracts/rpgAgentDraft.ts b/packages/shared/src/contracts/rpgAgentDraft.ts new file mode 100644 index 00000000..9339e234 --- /dev/null +++ b/packages/shared/src/contracts/rpgAgentDraft.ts @@ -0,0 +1,251 @@ +/** + * RPG Agent 草稿与资产覆盖率契约。 + * 这一层只描述 foundation draft、草稿卡片与资产状态,不包含会话编排语义。 + */ + +export type RpgAgentDraftCardKind = + | 'world' + | 'camp' + | 'faction' + | 'character' + | 'landmark' + | 'thread' + | 'chapter' + | 'scene_chapter' + | 'carrier' + | 'sidequest_seed'; + +export type RpgAgentDraftCardStatus = + | 'suggested' + | 'confirmed' + | 'locked' + | 'warning'; + +export interface RpgAgentDraftCardSummary { + id: string; + kind: RpgAgentDraftCardKind; + title: string; + subtitle: string; + summary: string; + status: RpgAgentDraftCardStatus; + linkedIds: string[]; + warningCount: number; + assetStatus?: RpgAgentRoleAssetStatus | null; + assetStatusLabel?: string | null; +} + +export interface RpgAgentDraftCardDetailSection { + id: string; + label: string; + value: string; +} + +export interface RpgAgentFoundationDraftFaction { + id: string; + name: string; + title?: string; + subtitle?: string; + publicGoal: string; + relatedConflict: string; + tension?: string; + playerRelation: string; + summary: string; +} + +export interface RpgAgentFoundationDraftCharacter { + id: string; + name: string; + title: string; + role: string; + publicIdentity: string; + publicMask?: string; + currentPressure: string; + hiddenHook?: string; + relationToPlayer: string; + threadIds: string[]; + summary: string; + skills?: Array<{ + id: string; + name: string; + actionPreviewConfig?: Record | null; + }>; + imageSrc?: string | null; + generatedVisualAssetId?: string | null; + generatedAnimationSetId?: string | null; + animationMap?: Record | null; +} + +export interface RpgAgentFoundationDraftLandmark { + id: string; + name: string; + description?: string; + purpose: string; + mood: string; + importance: string; + secret?: string; + dangerLevel?: string; + imageSrc?: string | null; + generatedSceneAssetId?: string | null; + generatedScenePrompt?: string | null; + generatedSceneModel?: string | null; + characterIds: string[]; + threadIds: string[]; + summary: string; +} + +export interface RpgAgentFoundationDraftThread { + id: string; + title: string; + type: 'main' | 'hidden'; + conflictType?: string; + conflict: string; + stakes?: string; + characterIds: string[]; + landmarkIds: string[]; + summary: string; +} + +export interface RpgAgentFoundationDraftChapter { + id: string; + title: string; + openingEvent: string; + playerGoal: string; + characterIds: string[]; + landmarkIds: string[]; + understandingShift: string; + summary: string; +} + +export interface RpgAgentFoundationDraftCamp { + id: string; + name: string; + description: string; + mood: string; + dangerLevel?: string; + imageSrc?: string | null; + generatedSceneAssetId?: string | null; + generatedScenePrompt?: string | null; + generatedSceneModel?: string | null; + summary: string; +} + +export type RpgAgentSceneActStage = + | 'opening' + | 'expansion' + | 'turning_point' + | 'climax' + | 'aftermath'; + +export type RpgAgentSceneActAdvanceRule = + | 'after_primary_contact' + | 'after_active_step_complete' + | 'after_chapter_resolution'; + +export interface RpgAgentFoundationDraftSceneAct { + id: string; + title: string; + summary: string; + stageCoverage: RpgAgentSceneActStage[]; + backgroundImageSrc?: string | null; + backgroundAssetId?: string | null; + encounterNpcIds: string[]; + primaryNpcId: string; + linkedThreadIds: string[]; + actGoal: string; + transitionHook: string; + advanceRule: RpgAgentSceneActAdvanceRule; +} + +export interface RpgAgentFoundationDraftSceneChapter { + id: string; + sceneId: string; + sceneName: string; + title: string; + summary: string; + linkedThreadIds: string[]; + linkedLandmarkIds: string[]; + acts: RpgAgentFoundationDraftSceneAct[]; +} + +export interface RpgAgentFoundationDraftProfile { + name: string; + subtitle: string; + summary: string; + tone: string; + playerGoal: string; + majorFactions: string[]; + coreConflicts: string[]; + playableNpcs: RpgAgentFoundationDraftCharacter[]; + storyNpcs: RpgAgentFoundationDraftCharacter[]; + landmarks: RpgAgentFoundationDraftLandmark[]; + camp?: RpgAgentFoundationDraftCamp | null; + themePack?: Record | null; + storyGraph?: Record | null; + factions: RpgAgentFoundationDraftFaction[]; + threads: RpgAgentFoundationDraftThread[]; + chapters: RpgAgentFoundationDraftChapter[]; + sceneChapters: RpgAgentFoundationDraftSceneChapter[]; + worldHook: string; + playerPremise: string; + openingSituation: string; + iconicElements: string[]; + sourceAnchorSummary: string; +} + +export interface RpgAgentFoundationDraftResult { + draftProfile: RpgAgentFoundationDraftProfile; + draftCards: RpgAgentDraftCardSummary[]; +} + +export interface RpgAgentDraftCardDetail { + id: string; + kind: RpgAgentDraftCardKind; + title: string; + sections: RpgAgentDraftCardDetailSection[]; + linkedIds: string[]; + locked: false; + editable: boolean; + editableSectionIds: string[]; + warningMessages: string[]; + assetStatus?: RpgAgentRoleAssetStatus | null; + assetStatusLabel?: string | null; +} + +export type RpgAgentAssetPriorityTier = 'hero' | 'featured' | 'supporting'; + +export type RpgAgentRoleAssetStatus = + | 'missing' + | 'visual_ready' + | 'animations_ready' + | 'complete'; + +export interface RpgAgentRoleAssetSummary { + roleId: string; + roleName: string; + roleKind: 'playable' | 'story'; + priorityTier: RpgAgentAssetPriorityTier; + portraitPath?: string | null; + generatedVisualAssetId?: string | null; + generatedAnimationSetId?: string | null; + status: RpgAgentRoleAssetStatus; + missingAnimations: string[]; + nextPointCost: number; +} + +export interface RpgAgentSceneAssetSummary { + sceneId: string; + sceneName: string; + actId?: string | null; + actTitle?: string | null; + imageSrc?: string | null; + assetId?: string | null; + status: 'missing' | 'ready'; + nextPointCost: number; +} + +export interface RpgAgentAssetCoverageSummary { + roleAssets: RpgAgentRoleAssetSummary[]; + sceneAssets: RpgAgentSceneAssetSummary[]; + allRoleAssetsReady: boolean; + allSceneAssetsReady: boolean; +} diff --git a/packages/shared/src/contracts/rpgAgentSession.ts b/packages/shared/src/contracts/rpgAgentSession.ts index d2c21fae..0bdb8ef1 100644 --- a/packages/shared/src/contracts/rpgAgentSession.ts +++ b/packages/shared/src/contracts/rpgAgentSession.ts @@ -1,15 +1,134 @@ -export type { - CreateCustomWorldAgentSessionRequest as CreateRpgAgentSessionRequest, - CreateCustomWorldAgentSessionResponse as CreateRpgAgentSessionResponse, - GetCustomWorldAgentCardDetailResponse as GetRpgAgentCardDetailResponse, - CustomWorldAgentMessage as RpgAgentMessage, - CustomWorldAgentMessageKind as RpgAgentMessageKind, - CustomWorldAgentMessageRole as RpgAgentMessageRole, - CustomWorldAgentOperationRecord as RpgAgentOperationRecord, - CustomWorldPendingClarification as RpgAgentPendingClarification, - CustomWorldAgentSessionSnapshot as RpgAgentSessionSnapshot, - CustomWorldAgentStage as RpgAgentStage, - CreatorIntentReadiness as RpgCreationIntentReadiness, - SendCustomWorldAgentMessageRequest as SendRpgAgentMessageRequest, - SendCustomWorldAgentMessageResponse as SendRpgAgentMessageResponse, -} from './customWorldAgent'; +import type { RpgAgentActionResponse, RpgAgentOperationRecord, RpgAgentSupportedAction, RpgAgentSuggestedAction } from './rpgAgentActions'; +import type { RpgCreationAnchorContent } from './rpgAgentAnchors'; +import type { RpgAgentAssetCoverageSummary, RpgAgentDraftCardDetail, RpgAgentDraftCardSummary } from './rpgAgentDraft'; +import type { RpgCreationPreviewEnvelope } from './rpgCreationPreview'; + +/** + * RPG Agent 会话层契约。 + * 这里承载 session 真相源与会话编排元数据,同时预留 resultPreview 与 supportedActions 两个后续主链字段。 + */ + +export interface RpgCreationIntentReadiness { + isReady: boolean; + completedKeys: string[]; + missingKeys: string[]; +} + +export interface RpgAgentPendingClarification { + id: string; + label: string; + question: string; + targetKey: + | 'world_hook' + | 'player_premise' + | 'theme_and_tone' + | 'core_conflict' + | 'relationship_seed' + | 'iconic_element'; + priority: number; + answer?: string; +} + +export type RpgAgentStage = + | 'collecting_intent' + | 'clarifying' + | 'foundation_review' + | 'object_refining' + | 'visual_refining' + | 'long_tail_review' + | 'ready_to_publish' + | 'published' + | 'error'; + +export type RpgAgentMessageRole = 'user' | 'assistant' | 'system'; + +export type RpgAgentMessageKind = + | 'chat' + | 'clarification' + | 'summary' + | 'checkpoint' + | 'warning' + | 'action_result'; + +export interface RpgAgentMessage { + id: string; + role: RpgAgentMessageRole; + kind: RpgAgentMessageKind; + text: string; + createdAt: string; + relatedOperationId?: string | null; +} + +export interface RpgAgentQualityFinding { + id: string; + severity: 'info' | 'warning' | 'blocker'; + code: string; + targetId?: string | null; + message: string; +} + +export interface RpgAgentSessionSnapshot { + sessionId: string; + currentTurn: number; + anchorContent: RpgCreationAnchorContent; + progressPercent: number; + lastAssistantReply: string | null; + stage: RpgAgentStage; + focusCardId: string | null; + creatorIntent: Record | null; + creatorIntentReadiness: RpgCreationIntentReadiness; + anchorPack: Record | null; + lockState: Record | null; + draftProfile: Record | null; + messages: RpgAgentMessage[]; + draftCards: RpgAgentDraftCardSummary[]; + pendingClarifications: RpgAgentPendingClarification[]; + suggestedActions: RpgAgentSuggestedAction[]; + recommendedReplies: string[]; + qualityFindings: RpgAgentQualityFinding[]; + assetCoverage: RpgAgentAssetCoverageSummary; + /** + * checkpoint 元数据需要进入 session snapshot 主链, + * 这样前端后续才能拿到真实可回滚目标,而不是只能盲发 checkpointId。 + */ + checkpoints?: Array<{ + checkpointId: string; + createdAt: string; + label: string; + }>; + /** + * 后续由工作包 E 的 action registry 真实填充。 + * 当前保持可选,确保主链迁移期间不影响旧 session snapshot。 + */ + supportedActions?: RpgAgentSupportedAction[]; + /** + * 后续由服务端 preview compiler 输出。 + * 当前保持可选,允许前端兼容层继续走 legacy profile。 + */ + resultPreview?: RpgCreationPreviewEnvelope | null; + updatedAt: string; +} + +export interface CreateRpgAgentSessionRequest { + seedText?: string; +} + +export interface CreateRpgAgentSessionResponse { + session: RpgAgentSessionSnapshot; +} + +export interface SendRpgAgentMessageRequest { + clientMessageId: string; + text: string; + quickFillRequested?: boolean; + focusCardId?: string | null; + selectedCardIds?: string[]; +} + +export interface SendRpgAgentMessageResponse extends RpgAgentActionResponse { + operation: RpgAgentOperationRecord; +} + +export interface GetRpgAgentCardDetailResponse { + card: RpgAgentDraftCardDetail; +} diff --git a/packages/shared/src/contracts/rpgContracts.test.ts b/packages/shared/src/contracts/rpgContracts.test.ts new file mode 100644 index 00000000..930d7e2a --- /dev/null +++ b/packages/shared/src/contracts/rpgContracts.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from 'vitest'; +import type { CustomWorldAgentSessionSnapshot } from './customWorldAgentSession'; +import type { CustomWorldResultPreviewEnvelope } from './customWorldResultPreview'; +import type { CustomWorldWorkSummary } from './customWorldWorkSummary'; + +import { + createRpgAgentFoundationDraftProfileFixture, + createRpgAgentSupportedActionsFixture, + createRpgAgentSessionFixture, + createRpgCreationAnchorContentFixture, + createRpgCreationPreviewEnvelopeFixture, + createRpgCreationPublishedProfileFixture, + createRpgCreationWorksResponseFixture, + createRpgWorldLibraryEntryFixture, +} from './rpgCreationFixtures'; + +describe('RPG 创作共享契约 fixture', () => { + test('旧命名兼容分文件可以直接承接新 fixture 的类型消费', () => { + const legacySession: CustomWorldAgentSessionSnapshot = + createRpgAgentSessionFixture(); + const legacyPreview: CustomWorldResultPreviewEnvelope = + createRpgCreationPreviewEnvelopeFixture(); + const legacyWork: CustomWorldWorkSummary = + createRpgCreationWorksResponseFixture().items[0]!; + + expect(legacySession.stage).toBe('ready_to_publish'); + expect(legacySession.resultPreview?.source).toBe(legacyPreview.source); + expect(legacyWork.status).toBe('draft'); + }); + + test('anchor fixture 与 foundation draft fixture 保持最小创作真相源对应关系', () => { + const anchors = createRpgCreationAnchorContentFixture(); + const draftProfile = createRpgAgentFoundationDraftProfileFixture(); + + expect(anchors.worldPromise?.hook).toContain('旧航路群岛'); + expect(draftProfile.worldHook).toContain('旧航路群岛'); + expect(draftProfile.playableNpcs).toHaveLength(1); + expect(draftProfile.storyNpcs).toHaveLength(1); + expect(draftProfile.sceneChapters[0]?.acts[0]?.backgroundImageSrc).toContain( + '/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png', + ); + }); + + test('session fixture 同时暴露 supportedActions 与 resultPreview', () => { + const session = createRpgAgentSessionFixture(); + + expect(session.sessionId).toBe('rpg-session-fixture'); + expect(session.stage).toBe('ready_to_publish'); + expect(session.checkpoints?.[0]?.checkpointId).toBe( + 'checkpoint-foundation-v1', + ); + expect(session.supportedActions?.map((entry) => entry.action)).toEqual( + expect.arrayContaining(['draft_foundation', 'generate_role_assets', 'publish_world']), + ); + expect(session.resultPreview?.source).toBe('session_preview'); + expect(session.resultPreview?.blockers).toEqual([]); + }); + + test('preview fixture 保持预览来源、质量结论与 profile 载体三层边界', () => { + const preview = createRpgCreationPreviewEnvelopeFixture(); + + expect(preview.source).toBe('session_preview'); + expect(preview.preview.previewId).toBe('preview-fixture-1'); + expect(preview.preview.sessionId).toBe('rpg-session-fixture'); + expect(preview.qualityFindings?.[0]).toMatchObject({ + severity: 'info', + code: 'scene_asset_ready', + }); + }); + + test('supported actions fixture 明确区分可执行能力矩阵,而不是让前端自行猜测按钮状态', () => { + const supportedActions = createRpgAgentSupportedActionsFixture(); + + expect(supportedActions).toEqual([ + { action: 'draft_foundation', enabled: true }, + { action: 'generate_role_assets', enabled: true }, + { action: 'publish_world', enabled: true }, + ]); + }); + + test('published profile fixture 能稳定承载作品库与结果页所需的封面、场景幕与角色资产字段', () => { + const profile = createRpgCreationPublishedProfileFixture(); + + expect(profile.id).toBe('rpg-profile-fixture'); + expect(profile.playableNpcs).toHaveLength(1); + expect(profile.landmarks).toHaveLength(1); + expect(profile.sceneChapterBlueprints).toHaveLength(1); + expect( + (profile.sceneChapterBlueprints as Array<{ acts?: Array<{ backgroundImageSrc?: string }> }>)[0] + ?.acts?.[0]?.backgroundImageSrc, + ).toContain('/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png'); + }); + + test('regression: session preview 与 published profile 需要共同保留角色动作资产和分幕背景字段', () => { + const session = createRpgAgentSessionFixture(); + const publishedProfile = createRpgCreationPublishedProfileFixture(); + const preview = createRpgCreationPreviewEnvelopeFixture(); + + expect( + ((session.draftProfile as { playableNpcs?: Array<{ animationMap?: { run?: { basePath?: string } } }> }) + .playableNpcs?.[0]?.animationMap?.run?.basePath ?? ''), + ).toContain('/generated-characters/playable-1/animations/run'); + expect( + ((preview.preview.playableNpcs as Array<{ generatedAnimationSetId?: string }>)[0] + ?.generatedAnimationSetId ?? ''), + ).toBe('animation-set-playable-1'); + expect( + ((publishedProfile.sceneChapterBlueprints as Array<{ + acts?: Array<{ backgroundAssetId?: string }>; + }>)[0]?.acts?.[0]?.backgroundAssetId ?? ''), + ).toBe('scene-asset-runtime'); + }); + + test('works fixture 与 library fixture 对齐同一 published profile', () => { + const works = createRpgCreationWorksResponseFixture(); + const libraryEntry = createRpgWorldLibraryEntryFixture(); + + const publishedWork = works.items.find((entry) => entry.status === 'published'); + + expect(publishedWork?.profileId).toBe(libraryEntry.profileId); + expect(publishedWork?.title).toBe(libraryEntry.worldName); + expect(publishedWork?.canEnterWorld).toBe(true); + expect(libraryEntry.profile.id).toBe(libraryEntry.profileId); + }); + + test('regression: works fixture 需要稳定保留草稿与发布态的作品门槛字段', () => { + const works = createRpgCreationWorksResponseFixture(); + const draftWork = works.items.find((entry) => entry.status === 'draft'); + const publishedWork = works.items.find((entry) => entry.status === 'published'); + + expect(draftWork).toMatchObject({ + stage: 'ready_to_publish', + stageLabel: '准备发布', + canResume: true, + canEnterWorld: false, + roleVisualReadyCount: 2, + roleAnimationReadyCount: 2, + }); + expect(publishedWork).toMatchObject({ + stage: 'published', + stageLabel: '已发布', + canResume: false, + canEnterWorld: true, + }); + }); +}); diff --git a/packages/shared/src/contracts/rpgCreationFixtures.ts b/packages/shared/src/contracts/rpgCreationFixtures.ts new file mode 100644 index 00000000..171ccff9 --- /dev/null +++ b/packages/shared/src/contracts/rpgCreationFixtures.ts @@ -0,0 +1,714 @@ +import type { + CustomWorldLibraryEntry, + CustomWorldProfileRecord, +} from './runtime'; +import type { RpgAgentSupportedAction } from './rpgAgentActions'; +import type { RpgCreationAnchorContent } from './rpgAgentAnchors'; +import type { + RpgAgentAssetCoverageSummary, + RpgAgentDraftCardSummary, + RpgAgentFoundationDraftProfile, +} from './rpgAgentDraft'; +import type { RpgAgentSessionSnapshot } from './rpgAgentSession'; +import type { RpgCreationPreviewEnvelope } from './rpgCreationPreview'; +import type { + ListRpgCreationWorksResponse, + RpgCreationWorkSummary, +} from './rpgCreationWorkSummary'; + +const RPG_CREATION_FIXTURE_SESSION_ID = 'rpg-session-fixture'; +const RPG_CREATION_FIXTURE_PROFILE_ID = 'rpg-profile-fixture'; +const RPG_CREATION_FIXTURE_USER_ID = 'fixture-user'; +const RPG_CREATION_FIXTURE_UPDATED_AT = '2026-04-21T09:30:00.000Z'; +const RPG_CREATION_FIXTURE_PUBLISHED_AT = '2026-04-21T10:00:00.000Z'; + +function cloneFixture(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +/** + * 共享八锚点 fixture。 + * 用于 contract test、session fixture 和 works 集成测试复用同一份创作意图样本。 + */ +export function createRpgCreationAnchorContentFixture(): RpgCreationAnchorContent { + return cloneFixture({ + worldPromise: { + hook: '被海雾吞没的旧航路群岛', + differentiator: '灯塔与禁航令共同决定谁能活着穿过去。', + desiredExperience: '压抑、悬疑、潮湿', + }, + playerFantasy: { + playerRole: '玩家回到群岛调查沉船真相。', + corePursuit: '找出失控航路背后的真相。', + fearOfLoss: '失去最后一个还能对上旧案的人。', + }, + themeBoundary: { + toneKeywords: ['压抑', '潮湿', '悬疑'], + aestheticDirectives: ['旧灯塔', '潮雾', '断裂航路'], + forbiddenDirectives: ['不要出现现代枪械'], + }, + playerEntryPoint: { + openingIdentity: '被迫返乡的失职守灯人', + openingProblem: '首夜就有陌生船只闯入禁航区。', + entryMotivation: '查清沉船夜里被谁改动了灯册。', + }, + coreConflict: { + surfaceConflicts: ['守灯会与航运公会争夺旧航路控制权'], + hiddenCrisis: '沉船夜的航灯与灯册被人动过手脚。', + firstTouchedConflict: '玩家开局就会撞上新的封航命令。', + }, + keyRelationships: [ + { + pairs: '玩家 / 沈砺', + relationshipType: '旧友兼潜在背叛者', + secretOrCost: '沈砺暗地里在替沉船商盟引路。', + }, + ], + hiddenLines: { + hiddenTruths: ['沉船夜的真实失误并不是单纯天灾。'], + misdirectionHints: ['所有人都会先把问题推给潮雾本身。'], + revealPacing: '第一章露出痕迹,第二章才让玩家摸到灯册线。', + }, + iconicElements: { + iconicMotifs: ['会移动的海雾'], + institutionsOrArtifacts: ['回潮旧灯塔', '封灯令', '旧潮图'], + hardRules: ['禁航信号一旦点亮,任何船都必须退航。'], + }, + } satisfies RpgCreationAnchorContent); +} + +/** + * 共享 foundation draft fixture。 + * 这份样本同时服务 session 草稿、preview 适配回归测试和 works 聚合测试。 + */ +export function createRpgAgentFoundationDraftProfileFixture(): RpgAgentFoundationDraftProfile { + return cloneFixture({ + name: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '第一版世界底稿已经整理完成,玩家将从回潮旧灯塔切入沉船旧案。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清沉船与禁航区异动的真相。', + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + playableNpcs: [ + { + id: 'playable-1', + name: '沈砺', + title: '旧航路引路人', + role: '关键同行者', + publicIdentity: '最熟悉旧航路的人。', + publicMask: '看上去像可靠旧友。', + currentPressure: '他必须在两股势力间站队。', + hiddenHook: '暗中替沉船商盟引路。', + relationToPlayer: '旧友兼潜在背叛者', + threadIds: ['thread-1'], + summary: '他像旧友,但也像一把始终没收回鞘的刀。', + skills: [ + { + id: 'skill-playable-1', + name: '潮行引路', + actionPreviewConfig: { + basePath: + '/generated-characters/playable-1/animations/skills/skill-playable-1', + }, + }, + ], + imageSrc: + '/generated-characters/playable-1/visual/asset-runtime/master.png', + generatedVisualAssetId: 'asset-runtime-playable', + generatedAnimationSetId: 'animation-set-playable-1', + animationMap: { + idle: { + basePath: '/generated-characters/playable-1/animations/idle', + }, + run: { + basePath: '/generated-characters/playable-1/animations/run', + }, + attack: { + basePath: '/generated-characters/playable-1/animations/attack', + }, + }, + }, + ], + storyNpcs: [ + { + id: 'story-1', + name: '顾潮音', + title: '守灯会值夜人', + role: '场景关键角色', + publicIdentity: '负责夜间巡灯与封锁。', + publicMask: '对外一直冷静克制。', + currentPressure: '她知道更多禁航区真相。', + hiddenHook: '曾亲眼见过失控海雾吞船。', + relationToPlayer: '最早愿意交换线索的人', + threadIds: ['thread-1'], + summary: '她总像比所有人更早知道海雾会往哪一侧压下来。', + skills: [ + { + id: 'skill-story-1', + name: '夜潮灯语', + actionPreviewConfig: { + basePath: + '/generated-characters/story-1/animations/skills/skill-story-1', + }, + }, + ], + imageSrc: + '/generated-characters/story-1/visual/asset-runtime/master.png', + generatedVisualAssetId: 'asset-runtime-story', + generatedAnimationSetId: 'animation-set-story-1', + animationMap: { + run: { + basePath: '/generated-characters/story-1/animations/run', + }, + attack: { + basePath: '/generated-characters/story-1/animations/attack', + }, + }, + }, + ], + landmarks: [ + { + id: 'landmark-1', + name: '回潮旧灯塔', + description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。', + purpose: '观察雾潮与往来船只', + mood: '潮湿、压抑、风声不止', + importance: '开局核心场景', + secret: '高处潮痕说明海面异常抬升过。', + dangerLevel: 'high', + imageSrc: '/generated-custom-world-scenes/landmark-1/latest-scene.png', + generatedSceneAssetId: 'scene-asset-landmark-1', + characterIds: ['story-1'], + threadIds: ['thread-1'], + summary: '旧灯塔是整片群岛最先看见异动的地方。', + }, + ], + camp: { + id: 'camp-1', + name: '回潮暂栖所', + description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。', + mood: '克制、紧绷,但还能暂时收拢局势', + dangerLevel: 'low', + imageSrc: '/custom/camp/huichao.png', + generatedSceneAssetId: 'scene-asset-camp-1', + summary: '玩家能在这里整理情报、回看旧灯册和沉船名单。', + }, + themePack: { + id: 'theme-pack:tide', + displayName: '潮雾悬疑', + }, + storyGraph: { + visibleThreads: [ + { + id: 'thread-visible-1', + title: '封航争夺', + }, + ], + }, + factions: [ + { + id: 'faction-1', + name: '守灯会', + title: '守灯会', + subtitle: '把控禁航灯令的人', + publicGoal: '维持封航秩序并压住灯册流出。', + relatedConflict: '想把旧案继续压在禁航记录之下。', + tension: '他们越强调规矩,越像在遮掩灯册。', + playerRelation: '玩家迟早要与他们正面冲突。', + summary: '掌握灯塔与封航令的势力,也是最怕旧案被翻出来的一方。', + }, + ], + threads: [ + { + id: 'thread-1', + title: '沉船旧案', + type: 'main', + conflictType: '真相遮蔽', + conflict: '沉船夜的航灯与灯册被人动过手脚。', + stakes: '真相一旦坐实,群岛秩序会先崩。', + characterIds: ['playable-1', 'story-1'], + landmarkIds: ['landmark-1'], + summary: '玩家会从灯塔高处潮痕一路追到沉船夜的真相。', + }, + ], + chapters: [ + { + id: 'chapter-1', + title: '灯塔回潮', + openingEvent: '禁航区闯入了一艘不该出现的陌生船。', + playerGoal: '先稳住局势,再拿到第一份灯册线索。', + characterIds: ['playable-1', 'story-1'], + landmarkIds: ['landmark-1'], + understandingShift: '玩家会意识到沉船旧案至今仍在操控群岛秩序。', + summary: '第一章聚焦灯塔与封航令,给玩家一条可追的旧案线索。', + }, + ], + sceneChapters: [ + { + id: 'scene-chapter-1', + sceneId: 'landmark-1', + sceneName: '回潮旧灯塔', + title: '灯塔初章', + summary: '围绕灯塔推进的首个场景章节。', + linkedThreadIds: ['thread-1'], + linkedLandmarkIds: ['landmark-1'], + acts: [ + { + id: 'scene-act-1', + title: '第一幕', + summary: '先接住回潮灯塔的入口压力。', + stageCoverage: ['opening'], + backgroundImageSrc: + '/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png', + backgroundAssetId: 'scene-asset-runtime', + encounterNpcIds: ['story-1'], + primaryNpcId: 'story-1', + linkedThreadIds: ['thread-1'], + actGoal: '接住首幕入口', + transitionHook: '向第二幕推进。', + advanceRule: 'after_primary_contact', + }, + ], + }, + ], + worldHook: '被海雾吞没的旧航路群岛', + playerPremise: '玩家回到群岛调查沉船真相。', + openingSituation: '首夜就有陌生船只闯入禁航区。', + iconicElements: ['会移动的海雾', '回潮旧灯塔'], + sourceAnchorSummary: '海雾、旧灯塔、失控航路。', + } satisfies RpgAgentFoundationDraftProfile); +} + +function createRpgAgentDraftCardsFixture(): RpgAgentDraftCardSummary[] { + return cloneFixture([ + { + id: 'world-foundation', + kind: 'world', + title: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '第一版世界底稿已经整理完成。', + status: 'suggested', + linkedIds: ['playable-1', 'story-1', 'landmark-1'], + warningCount: 0, + }, + { + id: 'playable-1', + kind: 'character', + title: '沈砺', + subtitle: '旧航路引路人 / 动作已齐', + summary: '最熟悉旧航路的人,也可能是最危险的旧友。', + status: 'suggested', + linkedIds: ['thread-1', 'landmark-1'], + warningCount: 0, + assetStatus: 'complete', + assetStatusLabel: '动作已齐', + }, + { + id: 'landmark-1', + kind: 'landmark', + title: '回潮旧灯塔', + subtitle: '观察雾潮与往来船只', + summary: '旧灯塔是整片群岛最先看见异动的地方。', + status: 'suggested', + linkedIds: ['story-1', 'thread-1'], + warningCount: 0, + }, + ] satisfies RpgAgentDraftCardSummary[]); +} + +function createRpgAgentAssetCoverageFixture(): RpgAgentAssetCoverageSummary { + return cloneFixture({ + roleAssets: [ + { + roleId: 'playable-1', + roleName: '沈砺', + roleKind: 'playable', + priorityTier: 'hero', + portraitPath: + '/generated-characters/playable-1/visual/asset-runtime/master.png', + generatedVisualAssetId: 'asset-runtime-playable', + generatedAnimationSetId: 'animation-set-playable-1', + status: 'complete', + missingAnimations: [], + nextPointCost: 0, + }, + { + roleId: 'story-1', + roleName: '顾潮音', + roleKind: 'story', + priorityTier: 'featured', + portraitPath: + '/generated-characters/story-1/visual/asset-runtime/master.png', + generatedVisualAssetId: 'asset-runtime-story', + generatedAnimationSetId: 'animation-set-story-1', + status: 'complete', + missingAnimations: [], + nextPointCost: 0, + }, + ], + sceneAssets: [ + { + sceneId: 'landmark-1', + sceneName: '回潮旧灯塔', + actId: 'scene-act-1', + actTitle: '第一幕', + imageSrc: + '/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png', + assetId: 'scene-asset-runtime', + status: 'ready', + nextPointCost: 0, + }, + ], + allRoleAssetsReady: true, + allSceneAssetsReady: true, + } satisfies RpgAgentAssetCoverageSummary); +} + +/** + * 已发布 profile fixture。 + * 用于 preview compiler、works 聚合和 library 元数据解析测试。 + */ +export function createRpgCreationPublishedProfileFixture(): CustomWorldProfileRecord { + const draft = createRpgAgentFoundationDraftProfileFixture(); + + return cloneFixture({ + id: RPG_CREATION_FIXTURE_PROFILE_ID, + settingText: draft.worldHook, + name: draft.name, + subtitle: draft.subtitle, + summary: draft.summary, + tone: draft.tone, + playerGoal: draft.playerGoal, + templateWorldType: 'WUXIA', + compatibilityTemplateWorldType: 'WUXIA', + majorFactions: draft.majorFactions, + coreConflicts: draft.coreConflicts, + playableNpcs: draft.playableNpcs.map((role) => ({ + id: role.id, + name: role.name, + title: role.title, + role: role.role, + description: role.publicIdentity, + backstory: role.hiddenHook || role.summary, + personality: role.publicMask || role.summary, + motivation: role.currentPressure, + combatStyle: '借地形和潮路换位,先拉扯再压近。', + initialAffinity: 18, + relationshipHooks: [role.relationToPlayer], + tags: ['潮路', '旧案'], + imageSrc: role.imageSrc, + generatedVisualAssetId: role.generatedVisualAssetId, + generatedAnimationSetId: role.generatedAnimationSetId, + animationMap: role.animationMap, + skills: [ + { + id: 'skill-playable-1', + name: '潮行引路', + summary: '踩着旧潮阶切线前压,替队伍打开角度。', + style: '机动周旋', + }, + ], + templateCharacterId: 'archer-hero', + })), + storyNpcs: draft.storyNpcs.map((role) => ({ + id: role.id, + name: role.name, + title: role.title, + role: role.role, + description: role.publicIdentity, + backstory: role.hiddenHook || role.summary, + personality: role.publicMask || role.summary, + motivation: role.currentPressure, + combatStyle: '借塔顶视角和风向压制,再用灯火错位扰乱。', + initialAffinity: 8, + relationshipHooks: [role.relationToPlayer], + tags: ['守灯会', '灯塔'], + imageSrc: role.imageSrc, + generatedVisualAssetId: role.generatedVisualAssetId, + skills: [ + { + id: 'skill-story-1', + name: '夜潮灯语', + summary: '借灯语与潮声干扰对方判断。', + style: '起手压制', + }, + ], + })), + camp: { + name: draft.camp?.name, + description: draft.camp?.description, + dangerLevel: draft.camp?.dangerLevel, + imageSrc: draft.camp?.imageSrc, + }, + landmarks: draft.landmarks.map((landmark) => ({ + id: landmark.id, + name: landmark.name, + description: landmark.description, + dangerLevel: landmark.dangerLevel, + imageSrc: landmark.imageSrc, + sceneNpcIds: landmark.characterIds, + connections: [ + { + targetLandmarkId: 'landmark-1', + relativePosition: 'forward', + summary: '沿着旧潮阶继续前压到雾栈尽头。', + }, + ], + })), + cover: { + sourceType: 'default', + characterRoleIds: ['playable-1'], + }, + sceneChapterBlueprints: draft.sceneChapters.map((chapter) => ({ + id: chapter.id, + sceneId: chapter.sceneId, + sceneName: chapter.sceneName, + title: chapter.title, + summary: chapter.summary, + acts: chapter.acts.map((act) => ({ + id: act.id, + title: act.title, + summary: act.summary, + backgroundImageSrc: act.backgroundImageSrc, + backgroundAssetId: act.backgroundAssetId, + encounterNpcIds: act.encounterNpcIds, + primaryNpcId: act.primaryNpcId, + actGoal: act.actGoal, + transitionHook: act.transitionHook, + })), + })), + themePack: draft.themePack, + storyGraph: draft.storyGraph, + scenarioPackId: 'scenario-pack:tide', + campaignPackId: 'campaign-pack:tide', + generationMode: 'fast', + generationStatus: 'key_only', + updatedAt: RPG_CREATION_FIXTURE_UPDATED_AT, + publishedAt: RPG_CREATION_FIXTURE_PUBLISHED_AT, + } satisfies CustomWorldProfileRecord); +} + +export function createRpgCreationPreviewEnvelopeFixture(): RpgCreationPreviewEnvelope { + return cloneFixture({ + preview: { + ...createRpgCreationPublishedProfileFixture(), + previewId: 'preview-fixture-1', + sessionId: RPG_CREATION_FIXTURE_SESSION_ID, + profileId: RPG_CREATION_FIXTURE_PROFILE_ID, + }, + source: 'session_preview', + generatedAt: RPG_CREATION_FIXTURE_UPDATED_AT, + qualityFindings: [ + { + id: 'finding-scene-asset-ready', + severity: 'info', + code: 'scene_asset_ready', + targetId: 'scene-act-1', + message: '首幕背景图已经就绪,可直接用于结果页预览。', + }, + ], + blockers: [], + publishReady: true, + canEnterWorld: false, + } satisfies RpgCreationPreviewEnvelope); +} + +export function createRpgAgentSupportedActionsFixture(): RpgAgentSupportedAction[] { + return cloneFixture([ + { + action: 'draft_foundation', + enabled: true, + }, + { + action: 'generate_role_assets', + enabled: true, + }, + { + action: 'publish_world', + enabled: true, + }, + ] satisfies RpgAgentSupportedAction[]); +} + +/** + * 共享 session snapshot fixture。 + * 默认模拟“底稿、预览、资产都已准备好”的 ready_to_publish 状态。 + */ +export function createRpgAgentSessionFixture(): RpgAgentSessionSnapshot { + const draftProfile = createRpgAgentFoundationDraftProfileFixture(); + + return cloneFixture({ + sessionId: RPG_CREATION_FIXTURE_SESSION_ID, + currentTurn: 6, + anchorContent: createRpgCreationAnchorContentFixture(), + progressPercent: 100, + lastAssistantReply: '八锚点与底稿都已经齐备,可以进入结果页收口。', + stage: 'ready_to_publish', + focusCardId: null, + creatorIntent: { + sourceMode: 'card', + rawSettingText: draftProfile.worldHook, + worldHook: draftProfile.worldHook, + playerPremise: draftProfile.playerPremise, + themeKeywords: ['海雾', '旧航路'], + toneDirectives: ['压抑', '悬疑'], + openingSituation: draftProfile.openingSituation, + coreConflicts: draftProfile.coreConflicts, + keyFactions: ['守灯会'], + keyCharacters: ['沈砺', '顾潮音'], + keyLandmarks: ['回潮旧灯塔'], + iconicElements: draftProfile.iconicElements, + forbiddenDirectives: ['不要出现现代枪械'], + }, + creatorIntentReadiness: { + isReady: true, + completedKeys: [ + 'world_hook', + 'player_premise', + 'theme_and_tone', + 'core_conflict', + 'relationship_seed', + 'iconic_element', + ], + missingKeys: [], + }, + anchorPack: { + summary: draftProfile.sourceAnchorSummary, + }, + lockState: { + lockedCardIds: ['world-foundation'], + }, + draftProfile, + messages: [ + { + id: 'message-1', + role: 'assistant', + kind: 'summary', + text: '世界底稿已整理完成,建议进入结果页确认资产与发布门槛。', + createdAt: RPG_CREATION_FIXTURE_UPDATED_AT, + relatedOperationId: null, + }, + ], + draftCards: createRpgAgentDraftCardsFixture(), + pendingClarifications: [], + suggestedActions: [ + { + id: 'action-publish', + type: 'publish_world', + label: '发布世界', + }, + ], + recommendedReplies: ['先看结果页', '继续精修角色关系'], + qualityFindings: [ + { + id: 'finding-scene-asset-ready', + severity: 'info', + code: 'scene_asset_ready', + targetId: 'scene-act-1', + message: '首幕背景图已经就绪,可直接用于结果页预览。', + }, + ], + assetCoverage: createRpgAgentAssetCoverageFixture(), + checkpoints: [ + { + checkpointId: 'checkpoint-foundation-v1', + createdAt: RPG_CREATION_FIXTURE_UPDATED_AT, + label: '世界底稿 V1', + }, + ], + supportedActions: createRpgAgentSupportedActionsFixture(), + resultPreview: createRpgCreationPreviewEnvelopeFixture(), + updatedAt: RPG_CREATION_FIXTURE_UPDATED_AT, + } satisfies RpgAgentSessionSnapshot); +} + +export function createRpgWorldLibraryEntryFixture(): CustomWorldLibraryEntry { + const profile = createRpgCreationPublishedProfileFixture(); + + return cloneFixture({ + ownerUserId: RPG_CREATION_FIXTURE_USER_ID, + profileId: RPG_CREATION_FIXTURE_PROFILE_ID, + profile, + visibility: 'published', + publishedAt: RPG_CREATION_FIXTURE_PUBLISHED_AT, + updatedAt: RPG_CREATION_FIXTURE_UPDATED_AT, + authorDisplayName: '测试玩家', + worldName: String(profile.name ?? '潮雾列岛'), + subtitle: String(profile.subtitle ?? '旧灯塔与失控航路'), + summaryText: String(profile.summary ?? '第一版世界底稿已经整理完成。'), + coverImageSrc: + '/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png', + themeMode: 'tide', + playableNpcCount: Array.isArray(profile.playableNpcs) + ? profile.playableNpcs.length + : 0, + landmarkCount: Array.isArray(profile.landmarks) + ? profile.landmarks.length + : 0, + } satisfies CustomWorldLibraryEntry); +} + +export function createRpgCreationWorksResponseFixture(): ListRpgCreationWorksResponse { + return cloneFixture({ + items: [ + { + workId: `draft:${RPG_CREATION_FIXTURE_SESSION_ID}`, + sourceType: 'agent_session', + status: 'draft', + title: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '第一版世界底稿已经整理完成,玩家将从回潮旧灯塔切入沉船旧案。', + coverImageSrc: '/custom/camp/huichao.png', + coverRenderMode: 'scene_with_roles', + coverCharacterImageSrcs: [ + '/generated-characters/playable-1/visual/asset-runtime/master.png', + ], + updatedAt: RPG_CREATION_FIXTURE_UPDATED_AT, + publishedAt: null, + stage: 'ready_to_publish', + stageLabel: '准备发布', + playableNpcCount: 2, + landmarkCount: 1, + roleVisualReadyCount: 2, + roleAnimationReadyCount: 2, + roleAssetSummaryLabel: '沈砺 · 动作已就绪', + sessionId: RPG_CREATION_FIXTURE_SESSION_ID, + profileId: null, + canResume: true, + canEnterWorld: false, + blockerCount: 0, + publishReady: true, + }, + { + workId: `published:${RPG_CREATION_FIXTURE_PROFILE_ID}`, + sourceType: 'published_profile', + status: 'published', + title: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '第一版世界底稿已经整理完成,玩家将从回潮旧灯塔切入沉船旧案。', + coverImageSrc: + '/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png', + coverRenderMode: 'scene_with_roles', + coverCharacterImageSrcs: [ + '/generated-characters/playable-1/visual/asset-runtime/master.png', + ], + updatedAt: RPG_CREATION_FIXTURE_UPDATED_AT, + publishedAt: RPG_CREATION_FIXTURE_PUBLISHED_AT, + stage: 'published', + stageLabel: '已发布', + playableNpcCount: 1, + landmarkCount: 1, + roleVisualReadyCount: 1, + roleAnimationReadyCount: 1, + roleAssetSummaryLabel: '动作已就绪 1', + sessionId: null, + profileId: RPG_CREATION_FIXTURE_PROFILE_ID, + canResume: false, + canEnterWorld: true, + blockerCount: 0, + publishReady: true, + }, + ] satisfies RpgCreationWorkSummary[], + } satisfies ListRpgCreationWorksResponse); +} diff --git a/packages/shared/src/contracts/rpgCreationPreview.ts b/packages/shared/src/contracts/rpgCreationPreview.ts index 317488a2..2c1c8e88 100644 --- a/packages/shared/src/contracts/rpgCreationPreview.ts +++ b/packages/shared/src/contracts/rpgCreationPreview.ts @@ -1,12 +1,40 @@ import type { CustomWorldProfileRecord } from './runtime'; /** - * 工作包 A 先建立 RPG 创作结果预览契约骨架。 - * 在服务端 preview compiler 正式落地前,这里继续把旧的世界 profile 视为兼容预览载体。 + * 结果页预览契约。 + * 当前 preview 仍以兼容 profile 作为承载体,但已经把来源、阻断项和质量结论从 session 草稿里显式剥离出来。 */ -export type RpgCreationPreview = CustomWorldProfileRecord; -export type RpgCreationPreviewEnvelope = { - preview: RpgCreationPreview; - source: 'legacy_custom_world_profile'; +export type RpgCreationPreviewSource = + | 'session_preview' + | 'published_profile'; + +export interface RpgCreationPreviewFinding { + id: string; + severity: 'info' | 'warning' | 'blocker'; + code: string; + targetId?: string | null; + message: string; +} + +export interface RpgCreationPreviewBlocker { + id: string; + code: string; + message: string; +} + +export type RpgCreationPreview = CustomWorldProfileRecord & { + previewId?: string; + sessionId?: string | null; + profileId?: string | null; }; + +export interface RpgCreationPreviewEnvelope { + preview: RpgCreationPreview; + source: RpgCreationPreviewSource; + generatedAt?: string; + qualityFindings?: RpgCreationPreviewFinding[]; + blockers?: RpgCreationPreviewBlocker[]; + publishReady?: boolean; + canEnterWorld?: boolean; +} diff --git a/packages/shared/src/contracts/rpgCreationWorkSummary.ts b/packages/shared/src/contracts/rpgCreationWorkSummary.ts index 9a874229..eef1b69c 100644 --- a/packages/shared/src/contracts/rpgCreationWorkSummary.ts +++ b/packages/shared/src/contracts/rpgCreationWorkSummary.ts @@ -1,6 +1,38 @@ -export type { - ListCustomWorldWorksResponse as ListRpgCreationWorksResponse, - CustomWorldWorkSource as RpgCreationWorkSource, - CustomWorldWorkStatus as RpgCreationWorkStatus, - CustomWorldWorkSummary as RpgCreationWorkSummary, -} from './customWorldAgent'; +/** + * RPG 创作作品卡读模型契约。 + * works 列表只暴露继续创作与进入世界判断所需的稳定字段。 + */ + +export type RpgCreationWorkStatus = 'draft' | 'published'; +export type RpgCreationWorkSource = 'agent_session' | 'published_profile'; + +export interface RpgCreationWorkSummary { + workId: string; + sourceType: RpgCreationWorkSource; + status: RpgCreationWorkStatus; + title: string; + subtitle: string; + summary: string; + coverImageSrc?: string | null; + coverRenderMode?: 'image' | 'scene_with_roles'; + coverCharacterImageSrcs?: string[]; + updatedAt: string; + publishedAt?: string | null; + stage?: string | null; + stageLabel?: string | null; + playableNpcCount: number; + landmarkCount: number; + roleVisualReadyCount?: number; + roleAnimationReadyCount?: number; + roleAssetSummaryLabel?: string | null; + sessionId?: string | null; + profileId?: string | null; + canResume: boolean; + canEnterWorld: boolean; + blockerCount?: number; + publishReady?: boolean; +} + +export interface ListRpgCreationWorksResponse { + items: RpgCreationWorkSummary[]; +} diff --git a/packages/shared/src/contracts/rpgRuntimeChat.ts b/packages/shared/src/contracts/rpgRuntimeChat.ts new file mode 100644 index 00000000..a08dff5c --- /dev/null +++ b/packages/shared/src/contracts/rpgRuntimeChat.ts @@ -0,0 +1,184 @@ +/** + * RPG 运行时聊天相关共享契约。 + * 将角色聊天、NPC 对话与轻量 story 请求载荷从旧 story.ts 中独立出来。 + */ +import type { JsonObject } from './common'; + +export type NpcChatTurnLimitReason = 'negative_affinity'; + +export type NpcChatTurnClosingMode = 'free' | 'foreshadow_close'; + +export type NpcChatTurnDirective = { + sceneActId?: string | null; + turnLimit?: number | null; + remainingTurns?: number | null; + limitReason?: NpcChatTurnLimitReason | null; + closingMode?: NpcChatTurnClosingMode | null; + forceExitAfterTurn?: boolean; +}; + +export type NpcChatTurnCompletionDirective = { + turnLimit?: number | null; + remainingTurns?: number | null; + forceExit?: boolean; + closingMode?: NpcChatTurnClosingMode; +}; + +export type CharacterChatReplyRequest< + TCharacter = unknown, + TStoryMoment = unknown, + TContext = unknown, + TConversationTurn = unknown, + TTargetStatus = unknown, +> = { + worldType: string; + playerCharacter: TCharacter; + targetCharacter: TCharacter; + storyHistory: TStoryMoment[]; + context: TContext; + conversationHistory: TConversationTurn[]; + conversationSummary: string; + playerMessage: string; + targetStatus: TTargetStatus; +}; + +export type CharacterChatSuggestionsRequest< + TCharacter = unknown, + TStoryMoment = unknown, + TContext = unknown, + TConversationTurn = unknown, + TTargetStatus = unknown, +> = { + worldType: string; + playerCharacter: TCharacter; + targetCharacter: TCharacter; + storyHistory: TStoryMoment[]; + context: TContext; + conversationHistory: TConversationTurn[]; + conversationSummary: string; + targetStatus: TTargetStatus; +}; + +export type CharacterChatSummaryRequest< + TCharacter = unknown, + TStoryMoment = unknown, + TContext = unknown, + TConversationTurn = unknown, + TTargetStatus = unknown, +> = { + worldType: string; + playerCharacter: TCharacter; + targetCharacter: TCharacter; + storyHistory: TStoryMoment[]; + context: TContext; + conversationHistory: TConversationTurn[]; + previousSummary: string; + targetStatus: TTargetStatus; +}; + +export type NpcChatDialogueRequest< + TCharacter = unknown, + TEncounter = unknown, + TMonster = unknown, + TStoryMoment = unknown, + TContext = unknown, +> = { + worldType: string; + character: TCharacter; + encounter: TEncounter; + monsters: TMonster[]; + history: TStoryMoment[]; + context: TContext; + topic: string; + resultSummary: string; + npcInitiatesConversation?: boolean; +}; + +export type NpcChatTurnRequest< + TCharacter = unknown, + TEncounter = unknown, + TMonster = unknown, + TStoryMoment = unknown, + TContext = unknown, + TConversationTurn = unknown, + TCombatContext = unknown, + TNpcState = unknown, + TQuestOfferState = unknown, + TQuestOfferEncounter = unknown, + TChatDirective = NpcChatTurnDirective, +> = { + worldType: string; + character?: TCharacter; + player?: TCharacter; + encounter: TEncounter; + monsters: TMonster[]; + history: TStoryMoment[]; + context: TContext; + conversationHistory?: TConversationTurn[]; + dialogue?: TConversationTurn[]; + combatContext?: TCombatContext | null; + playerMessage: string; + npcState: TNpcState; + npcInitiatesConversation?: boolean; + questOfferContext?: { + state: TQuestOfferState; + encounter: TQuestOfferEncounter; + turnCount: number; + } | null; + chatDirective?: TChatDirective | null; +}; + +export type NpcChatPendingQuestOffer = { + quest: TQuest; + introText?: string; +}; + +export type NpcChatTurnResult = { + npcReply: string; + affinityDelta: number; + affinityText: string; + suggestions: string[]; + pendingQuestOffer?: NpcChatPendingQuestOffer | null; + chatDirective?: NpcChatTurnCompletionDirective | null; +}; + +export type NpcRecruitDialogueRequest< + TCharacter = unknown, + TEncounter = unknown, + TMonster = unknown, + TStoryMoment = unknown, + TContext = unknown, +> = { + worldType: string; + character: TCharacter; + encounter: TEncounter; + monsters: TMonster[]; + history: TStoryMoment[]; + context: TContext; + invitationText: string; + recruitSummary: string; +}; + +export type StoryRequestOptionsPayload = { + availableOptions?: JsonObject[]; + optionCatalog?: JsonObject[]; +}; + +export type StoryRequestPayload = { + worldType: TWorldType; + character: JsonObject; + monsters?: JsonObject[]; + history?: JsonObject[]; + choice?: string; + context: JsonObject; + requestOptions?: StoryRequestOptionsPayload; +}; + +export type PlainTextPromptRequest = { + systemPrompt: string; + userPrompt: string; +}; + +export type PlainTextResponse = { + text: string; +}; diff --git a/packages/shared/src/contracts/rpgRuntimeContracts.test.ts b/packages/shared/src/contracts/rpgRuntimeContracts.test.ts new file mode 100644 index 00000000..e1e8fff0 --- /dev/null +++ b/packages/shared/src/contracts/rpgRuntimeContracts.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'vitest'; + +import { + CharacterChatReplyRequest, + QUEST_NARRATIVE_TYPES, + RuntimeStoryActionRequest, + SERVER_RUNTIME_FUNCTION_IDS, + TASK6_RUNTIME_FUNCTION_IDS, +} from './story'; +import { TASK5_RUNTIME_OPTION_SCOPES } from './rpgRuntimeStoryAction'; +import type { RuntimeStoryStateRequest } from './rpgRuntimeStoryState'; + +describe('RPG runtime shared contract façades', () => { + test('旧 story façade 继续导出 runtime story action 常量与类型', () => { + expect(SERVER_RUNTIME_FUNCTION_IDS).toContain('npc_chat'); + expect(TASK6_RUNTIME_FUNCTION_IDS).toContain('npc_trade'); + expect(TASK5_RUNTIME_OPTION_SCOPES).toEqual(['story', 'combat', 'npc']); + + const request: RuntimeStoryActionRequest = { + sessionId: 'runtime-session-1', + action: { + type: 'story_choice', + functionId: 'npc_chat', + }, + }; + + expect(request.action.functionId).toBe('npc_chat'); + }); + + test('旧 story façade 继续导出 chat 与 quest assist 契约', () => { + const payload: CharacterChatReplyRequest = { + worldType: 'WUXIA', + playerCharacter: {}, + targetCharacter: {}, + storyHistory: [], + context: {}, + conversationHistory: [], + conversationSummary: '测试摘要', + playerMessage: '近况如何?', + targetStatus: {}, + }; + + const stateRequest: RuntimeStoryStateRequest = { + sessionId: 'runtime-session-2', + }; + + expect(payload.playerMessage).toBe('近况如何?'); + expect(stateRequest.sessionId).toBe('runtime-session-2'); + expect(QUEST_NARRATIVE_TYPES).toContain('relationship'); + }); +}); diff --git a/packages/shared/src/contracts/rpgRuntimeQuestAssist.ts b/packages/shared/src/contracts/rpgRuntimeQuestAssist.ts new file mode 100644 index 00000000..03d44ca1 --- /dev/null +++ b/packages/shared/src/contracts/rpgRuntimeQuestAssist.ts @@ -0,0 +1,83 @@ +/** + * RPG 运行时任务辅助与道具意图共享契约。 + * 该文件只承载 quest / runtime item 辅助类型,不混入 runtime story 主状态。 + */ +import type { JsonObject } from './common'; + +export const QUEST_NARRATIVE_TYPES = [ + 'bounty', + 'escort', + 'investigation', + 'retrieval', + 'relationship', + 'trial', +] as const; +export type SharedQuestNarrativeType = (typeof QUEST_NARRATIVE_TYPES)[number]; + +export const QUEST_OBJECTIVE_KINDS = [ + 'defeat_hostile_npc', + 'inspect_treasure', + 'spar_with_npc', + 'talk_to_npc', + 'reach_scene', + 'deliver_item', +] as const; +export type SharedQuestObjectiveKind = (typeof QUEST_OBJECTIVE_KINDS)[number]; + +export const QUEST_URGENCY_LEVELS = ['low', 'medium', 'high'] as const; +export type SharedQuestUrgency = (typeof QUEST_URGENCY_LEVELS)[number]; + +export const QUEST_INTIMACY_LEVELS = [ + 'transactional', + 'cooperative', + 'trust_based', +] as const; +export type SharedQuestIntimacy = (typeof QUEST_INTIMACY_LEVELS)[number]; + +export const QUEST_REWARD_THEMES = [ + 'currency', + 'resource', + 'relationship', + 'intel', + 'rare_item', +] as const; +export type SharedQuestRewardTheme = (typeof QUEST_REWARD_THEMES)[number]; + +export const RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES = [ + 'heal', + 'mana', + 'cooldown', + 'guard', + 'damage', +] as const; +export type SharedRuntimeItemFunctionalBias = + (typeof RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES)[number]; + +export const RUNTIME_ITEM_TONE_VALUES = [ + 'grim', + 'mysterious', + 'martial', + 'ritual', + 'survival', +] as const; +export type SharedRuntimeItemTone = (typeof RUNTIME_ITEM_TONE_VALUES)[number]; + +export type RuntimeItemIntentRequest< + TContext = JsonObject, + TPlan = JsonObject, +> = { + context: TContext; + plans: TPlan[]; +}; + +export type RuntimeItemIntentResponse = { + intents: TIntent[]; +}; + +export type QuestGenerationRequest< + TState = JsonObject, + TEncounter = JsonObject, +> = { + state: TState; + encounter: TEncounter; +}; diff --git a/packages/shared/src/contracts/rpgRuntimeStoryAction.ts b/packages/shared/src/contracts/rpgRuntimeStoryAction.ts new file mode 100644 index 00000000..12d320bd --- /dev/null +++ b/packages/shared/src/contracts/rpgRuntimeStoryAction.ts @@ -0,0 +1,136 @@ +/** + * RPG runtime story 动作层共享契约。 + * 将 function id、动作请求与交互元数据从旧 story.ts 中单独收口。 + */ +import type { JsonObject } from './common'; + +export type RuntimeAction< + TType extends string = string, + TPayload = JsonObject, +> = { + type: TType; + functionId?: string; + targetId?: string; + payload?: TPayload; +}; + +export type RuntimeActionRequest< + TAction extends RuntimeAction = RuntimeAction, +> = { + sessionId: string; + clientVersion?: number; + action: TAction; +}; + +export type RuntimeActionResponse< + TViewModel = JsonObject, + TPresentation = JsonObject, + TPatch = JsonObject, +> = { + sessionId: string; + serverVersion: number; + viewModel: TViewModel; + presentation: TPresentation; + patches: TPatch[]; +}; + +export const TASK5_RUNTIME_FUNCTION_IDS = [ + 'story_continue_adventure', + 'story_opening_camp_dialogue', + 'camp_travel_home_scene', + 'idle_call_out', + 'idle_explore_forward', + 'idle_observe_signs', + 'idle_rest_focus', + 'idle_travel_next_scene', + 'battle_attack_basic', + 'battle_use_skill', + 'battle_all_in_crush', + 'battle_escape_breakout', + 'battle_feint_step', + 'battle_finisher_window', + 'battle_guard_break', + 'battle_probe_pressure', + 'battle_recover_breath', + 'npc_chat', + 'npc_fight', + 'npc_help', + 'npc_leave', + 'npc_preview_talk', + 'npc_recruit', + 'npc_spar', +] as const; +export type Task5RuntimeFunctionId = + (typeof TASK5_RUNTIME_FUNCTION_IDS)[number]; + +export const TASK6_RUNTIME_FUNCTION_IDS = [ + 'equipment_equip', + 'equipment_unequip', + 'forge_craft', + 'forge_dismantle', + 'forge_reforge', + 'inventory_use', + 'npc_gift', + 'npc_chat_quest_offer_abandon', + 'npc_chat_quest_offer_replace', + 'npc_chat_quest_offer_view', + 'npc_quest_accept', + 'npc_quest_turn_in', + 'npc_trade', + 'treasure_inspect', + 'treasure_leave', + 'treasure_secure', +] as const; +export type Task6RuntimeFunctionId = + (typeof TASK6_RUNTIME_FUNCTION_IDS)[number]; + +export const SERVER_RUNTIME_FUNCTION_IDS = [ + ...TASK5_RUNTIME_FUNCTION_IDS, + ...TASK6_RUNTIME_FUNCTION_IDS, +] as const; +export type ServerRuntimeFunctionId = + (typeof SERVER_RUNTIME_FUNCTION_IDS)[number]; + +export const TASK5_RUNTIME_OPTION_SCOPES = ['story', 'combat', 'npc'] as const; +export type Task5RuntimeOptionScope = + (typeof TASK5_RUNTIME_OPTION_SCOPES)[number]; + +export type RuntimeStoryChoicePayload = JsonObject & { + optionText?: string; + note?: string; + releaseNpcId?: string; + preludeText?: string; +}; + +export type RuntimeStoryOptionInteraction = + | { + kind: 'npc'; + npcId: string; + action: + | 'chat' + | 'help' + | 'fight' + | 'leave' + | 'quest_offer_abandon' + | 'quest_offer_replace' + | 'quest_offer_view' + | 'recruit' + | 'spar' + | 'trade' + | 'gift' + | 'quest_accept' + | 'quest_turn_in'; + questId?: string; + } + | { + kind: 'treasure'; + action: 'inspect' | 'leave' | 'secure'; + }; + +export type RuntimeStoryChoiceAction = RuntimeAction< + 'story_choice', + RuntimeStoryChoicePayload +> & { + functionId: string; + targetId?: string; +}; diff --git a/packages/shared/src/contracts/rpgRuntimeStoryState.ts b/packages/shared/src/contracts/rpgRuntimeStoryState.ts new file mode 100644 index 00000000..ab6b4fe9 --- /dev/null +++ b/packages/shared/src/contracts/rpgRuntimeStoryState.ts @@ -0,0 +1,146 @@ +/** + * RPG runtime story 状态与响应共享契约。 + * 该文件只负责 view model、presentation、patch 与 snapshot 回包结构。 + */ +import type { JsonObject } from './common'; +import type { SavedGameSnapshot, SavedGameSnapshotInput } from './runtime'; +import type { + RuntimeActionRequest, + RuntimeActionResponse, + RuntimeStoryChoiceAction, + RuntimeStoryChoicePayload, + RuntimeStoryOptionInteraction, + Task5RuntimeOptionScope, +} from './rpgRuntimeStoryAction'; + +export type RuntimeStoryOptionView = { + functionId: string; + actionText: string; + detailText?: string; + scope: Task5RuntimeOptionScope; + interaction?: RuntimeStoryOptionInteraction; + payload?: RuntimeStoryChoicePayload; + disabled?: boolean; + reason?: string; +}; + +export type RuntimeStoryPlayerViewModel = { + hp: number; + maxHp: number; + mana: number; + maxMana: number; +}; + +export type RuntimeStoryCompanionViewModel = { + npcId: string; + characterId?: string; + joinedAtAffinity: number; +}; + +export type RuntimeStoryEncounterViewModel = { + id: string; + kind: 'npc' | 'treasure'; + npcName: string; + hostile: boolean; + affinity?: number; + recruited?: boolean; + interactionActive: boolean; + battleMode?: 'fight' | 'spar' | null; +}; + +export type RuntimeStoryStatusViewModel = { + inBattle: boolean; + npcInteractionActive: boolean; + currentNpcBattleMode: 'fight' | 'spar' | null; + currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null; +}; + +export type RuntimeBattlePresentation = { + targetId?: string; + targetName?: string; + damageDealt?: number; + damageTaken?: number; + outcome?: 'ongoing' | 'victory' | 'spar_complete' | 'escaped'; +}; + +export type RuntimeStoryViewModel = { + player: RuntimeStoryPlayerViewModel; + encounter: RuntimeStoryEncounterViewModel | null; + companions: RuntimeStoryCompanionViewModel[]; + availableOptions: RuntimeStoryOptionView[]; + status: RuntimeStoryStatusViewModel; +}; + +export type RuntimeStoryPresentation = { + actionText: string; + resultText: string; + storyText: string; + options: RuntimeStoryOptionView[]; + toast?: string | null; + battle?: RuntimeBattlePresentation | null; +}; + +export type RuntimeStoryPatch = + | { + type: 'story_history_append'; + actionText: string; + resultText: string; + } + | { + type: 'npc_affinity_changed'; + npcId: string; + previousAffinity: number; + nextAffinity: number; + } + | { + type: 'battle_resolved'; + functionId: string; + targetId?: string; + damageDealt?: number; + damageTaken?: number; + outcome: 'ongoing' | 'victory' | 'spar_complete' | 'escaped'; + } + | { + type: 'status_changed'; + inBattle: boolean; + npcInteractionActive: boolean; + currentNpcBattleMode: 'fight' | 'spar' | null; + currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null; + } + | { + type: 'encounter_changed'; + encounterId: string | null; + }; + +export type RuntimeStoryActionRequest = + RuntimeActionRequest & { + snapshot?: SavedGameSnapshotInput; + }; + +export type RuntimeStoryStateRequest< + TSnapshotGameState = JsonObject, + TSnapshotCurrentStory = JsonObject, +> = { + sessionId: string; + clientVersion?: number; + snapshot?: SavedGameSnapshotInput< + TSnapshotGameState, + string, + TSnapshotCurrentStory + >; +}; + +export type RuntimeStoryActionResponse< + TSnapshotGameState = JsonObject, + TSnapshotCurrentStory = JsonObject, +> = RuntimeActionResponse< + RuntimeStoryViewModel, + RuntimeStoryPresentation, + RuntimeStoryPatch +> & { + snapshot: SavedGameSnapshot< + TSnapshotGameState, + string, + TSnapshotCurrentStory + >; +}; diff --git a/packages/shared/src/contracts/story.ts b/packages/shared/src/contracts/story.ts deleted file mode 100644 index 025a0c1f..00000000 --- a/packages/shared/src/contracts/story.ts +++ /dev/null @@ -1,520 +0,0 @@ -import type { JsonObject } from './common'; -import type { SavedGameSnapshot } from './runtime'; - -export const QUEST_NARRATIVE_TYPES = [ - 'bounty', - 'escort', - 'investigation', - 'retrieval', - 'relationship', - 'trial', -] as const; -export type SharedQuestNarrativeType = (typeof QUEST_NARRATIVE_TYPES)[number]; - -export const QUEST_OBJECTIVE_KINDS = [ - 'defeat_hostile_npc', - 'inspect_treasure', - 'spar_with_npc', - 'talk_to_npc', - 'reach_scene', - 'deliver_item', -] as const; -export type SharedQuestObjectiveKind = (typeof QUEST_OBJECTIVE_KINDS)[number]; - -export const QUEST_URGENCY_LEVELS = ['low', 'medium', 'high'] as const; -export type SharedQuestUrgency = (typeof QUEST_URGENCY_LEVELS)[number]; - -export const QUEST_INTIMACY_LEVELS = [ - 'transactional', - 'cooperative', - 'trust_based', -] as const; -export type SharedQuestIntimacy = (typeof QUEST_INTIMACY_LEVELS)[number]; - -export const QUEST_REWARD_THEMES = [ - 'currency', - 'resource', - 'relationship', - 'intel', - 'rare_item', -] as const; -export type SharedQuestRewardTheme = (typeof QUEST_REWARD_THEMES)[number]; - -export const RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES = [ - 'heal', - 'mana', - 'cooldown', - 'guard', - 'damage', -] as const; -export type SharedRuntimeItemFunctionalBias = - (typeof RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES)[number]; - -export const RUNTIME_ITEM_TONE_VALUES = [ - 'grim', - 'mysterious', - 'martial', - 'ritual', - 'survival', -] as const; -export type SharedRuntimeItemTone = (typeof RUNTIME_ITEM_TONE_VALUES)[number]; - -export type StoryRequestOptionsPayload = { - availableOptions?: JsonObject[]; - optionCatalog?: JsonObject[]; -}; - -export type StoryRequestPayload = { - worldType: TWorldType; - character: JsonObject; - monsters?: JsonObject[]; - history?: JsonObject[]; - choice?: string; - context: JsonObject; - requestOptions?: StoryRequestOptionsPayload; -}; - -export type PlainTextPromptRequest = { - systemPrompt: string; - userPrompt: string; -}; - -export type PlainTextResponse = { - text: string; -}; - -export type NpcChatTurnLimitReason = 'negative_affinity'; - -export type NpcChatTurnClosingMode = 'free' | 'foreshadow_close'; - -export type NpcChatTurnDirective = { - sceneActId?: string | null; - turnLimit?: number | null; - remainingTurns?: number | null; - limitReason?: NpcChatTurnLimitReason | null; - closingMode?: NpcChatTurnClosingMode | null; - forceExitAfterTurn?: boolean; -}; - -export type NpcChatTurnCompletionDirective = { - turnLimit?: number | null; - remainingTurns?: number | null; - forceExit?: boolean; - closingMode?: NpcChatTurnClosingMode; -}; - -export type CharacterChatReplyRequest< - TCharacter = unknown, - TStoryMoment = unknown, - TContext = unknown, - TConversationTurn = unknown, - TTargetStatus = unknown, -> = { - worldType: string; - playerCharacter: TCharacter; - targetCharacter: TCharacter; - storyHistory: TStoryMoment[]; - context: TContext; - conversationHistory: TConversationTurn[]; - conversationSummary: string; - playerMessage: string; - targetStatus: TTargetStatus; -}; - -export type CharacterChatSuggestionsRequest< - TCharacter = unknown, - TStoryMoment = unknown, - TContext = unknown, - TConversationTurn = unknown, - TTargetStatus = unknown, -> = { - worldType: string; - playerCharacter: TCharacter; - targetCharacter: TCharacter; - storyHistory: TStoryMoment[]; - context: TContext; - conversationHistory: TConversationTurn[]; - conversationSummary: string; - targetStatus: TTargetStatus; -}; - -export type CharacterChatSummaryRequest< - TCharacter = unknown, - TStoryMoment = unknown, - TContext = unknown, - TConversationTurn = unknown, - TTargetStatus = unknown, -> = { - worldType: string; - playerCharacter: TCharacter; - targetCharacter: TCharacter; - storyHistory: TStoryMoment[]; - context: TContext; - conversationHistory: TConversationTurn[]; - previousSummary: string; - targetStatus: TTargetStatus; -}; - -export type NpcChatDialogueRequest< - TCharacter = unknown, - TEncounter = unknown, - TMonster = unknown, - TStoryMoment = unknown, - TContext = unknown, -> = { - worldType: string; - character: TCharacter; - encounter: TEncounter; - monsters: TMonster[]; - history: TStoryMoment[]; - context: TContext; - topic: string; - resultSummary: string; - npcInitiatesConversation?: boolean; -}; - -export type NpcChatTurnRequest< - TCharacter = unknown, - TEncounter = unknown, - TMonster = unknown, - TStoryMoment = unknown, - TContext = unknown, - TConversationTurn = unknown, - TCombatContext = unknown, - TNpcState = unknown, - TQuestOfferState = unknown, - TQuestOfferEncounter = unknown, - TChatDirective = NpcChatTurnDirective, -> = { - worldType: string; - character?: TCharacter; - player?: TCharacter; - encounter: TEncounter; - monsters: TMonster[]; - history: TStoryMoment[]; - context: TContext; - conversationHistory?: TConversationTurn[]; - dialogue?: TConversationTurn[]; - combatContext?: TCombatContext | null; - playerMessage: string; - npcState: TNpcState; - npcInitiatesConversation?: boolean; - questOfferContext?: { - state: TQuestOfferState; - encounter: TQuestOfferEncounter; - turnCount: number; - } | null; - chatDirective?: TChatDirective | null; -}; - -export type NpcChatPendingQuestOffer = { - quest: TQuest; - introText?: string; -}; - -export type NpcChatTurnResult = { - npcReply: string; - affinityDelta: number; - affinityText: string; - suggestions: string[]; - pendingQuestOffer?: NpcChatPendingQuestOffer | null; - chatDirective?: NpcChatTurnCompletionDirective | null; -}; - -export type NpcRecruitDialogueRequest< - TCharacter = unknown, - TEncounter = unknown, - TMonster = unknown, - TStoryMoment = unknown, - TContext = unknown, -> = { - worldType: string; - character: TCharacter; - encounter: TEncounter; - monsters: TMonster[]; - history: TStoryMoment[]; - context: TContext; - invitationText: string; - recruitSummary: string; -}; - -export type RuntimeItemIntentRequest< - TContext = JsonObject, - TPlan = JsonObject, -> = { - context: TContext; - plans: TPlan[]; -}; - -export type RuntimeItemIntentResponse = { - intents: TIntent[]; -}; - -export type QuestGenerationRequest< - TState = JsonObject, - TEncounter = JsonObject, -> = { - state: TState; - encounter: TEncounter; -}; - -export type RuntimeAction< - TType extends string = string, - TPayload = JsonObject, -> = { - type: TType; - functionId?: string; - targetId?: string; - payload?: TPayload; -}; - -export type RuntimeActionRequest< - TAction extends RuntimeAction = RuntimeAction, -> = { - sessionId: string; - clientVersion?: number; - action: TAction; -}; - -export type RuntimeActionResponse< - TViewModel = JsonObject, - TPresentation = JsonObject, - TPatch = JsonObject, -> = { - sessionId: string; - serverVersion: number; - viewModel: TViewModel; - presentation: TPresentation; - patches: TPatch[]; -}; - -export const TASK5_RUNTIME_FUNCTION_IDS = [ - 'story_continue_adventure', - 'story_opening_camp_dialogue', - 'camp_travel_home_scene', - 'idle_call_out', - 'idle_explore_forward', - 'idle_observe_signs', - 'idle_rest_focus', - 'idle_travel_next_scene', - 'battle_attack_basic', - 'battle_use_skill', - 'battle_all_in_crush', - 'battle_escape_breakout', - 'battle_feint_step', - 'battle_finisher_window', - 'battle_guard_break', - 'battle_probe_pressure', - 'battle_recover_breath', - 'npc_chat', - 'npc_fight', - 'npc_help', - 'npc_leave', - 'npc_preview_talk', - 'npc_recruit', - 'npc_spar', -] as const; -export type Task5RuntimeFunctionId = - (typeof TASK5_RUNTIME_FUNCTION_IDS)[number]; - -export const TASK6_RUNTIME_FUNCTION_IDS = [ - 'equipment_equip', - 'equipment_unequip', - 'forge_craft', - 'forge_dismantle', - 'forge_reforge', - 'inventory_use', - 'npc_gift', - 'npc_chat_quest_offer_abandon', - 'npc_chat_quest_offer_replace', - 'npc_chat_quest_offer_view', - 'npc_quest_accept', - 'npc_quest_turn_in', - 'npc_trade', - 'treasure_inspect', - 'treasure_leave', - 'treasure_secure', -] as const; -export type Task6RuntimeFunctionId = - (typeof TASK6_RUNTIME_FUNCTION_IDS)[number]; - -export const SERVER_RUNTIME_FUNCTION_IDS = [ - ...TASK5_RUNTIME_FUNCTION_IDS, - ...TASK6_RUNTIME_FUNCTION_IDS, -] as const; -export type ServerRuntimeFunctionId = - (typeof SERVER_RUNTIME_FUNCTION_IDS)[number]; - -export const TASK5_RUNTIME_OPTION_SCOPES = ['story', 'combat', 'npc'] as const; -export type Task5RuntimeOptionScope = - (typeof TASK5_RUNTIME_OPTION_SCOPES)[number]; - -export type RuntimeStoryChoicePayload = JsonObject & { - optionText?: string; - note?: string; -}; - -export type RuntimeStoryOptionInteraction = - | { - kind: 'npc'; - npcId: string; - action: - | 'chat' - | 'help' - | 'fight' - | 'leave' - | 'quest_offer_abandon' - | 'quest_offer_replace' - | 'quest_offer_view' - | 'recruit' - | 'spar' - | 'trade' - | 'gift' - | 'quest_accept' - | 'quest_turn_in'; - questId?: string; - } - | { - kind: 'treasure'; - action: 'inspect' | 'leave' | 'secure'; - }; - -export type RuntimeStoryChoiceAction = RuntimeAction< - 'story_choice', - RuntimeStoryChoicePayload -> & { - functionId: string; - targetId?: string; -}; - -export type RuntimeStoryOptionView = { - functionId: string; - actionText: string; - detailText?: string; - scope: Task5RuntimeOptionScope; - interaction?: RuntimeStoryOptionInteraction; - payload?: RuntimeStoryChoicePayload; - disabled?: boolean; - reason?: string; -}; - -export type RuntimeStoryPlayerViewModel = { - hp: number; - maxHp: number; - mana: number; - maxMana: number; -}; - -export type RuntimeStoryCompanionViewModel = { - npcId: string; - characterId?: string; - joinedAtAffinity: number; -}; - -export type RuntimeStoryEncounterViewModel = { - id: string; - kind: 'npc' | 'treasure'; - npcName: string; - hostile: boolean; - affinity?: number; - recruited?: boolean; - interactionActive: boolean; - battleMode?: 'fight' | 'spar' | null; -}; - -export type RuntimeStoryStatusViewModel = { - inBattle: boolean; - npcInteractionActive: boolean; - currentNpcBattleMode: 'fight' | 'spar' | null; - currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null; -}; - -export type RuntimeBattlePresentation = { - targetId?: string; - targetName?: string; - damageDealt?: number; - damageTaken?: number; - outcome?: 'ongoing' | 'victory' | 'spar_complete' | 'escaped'; -}; - -export type RuntimeStoryViewModel = { - player: RuntimeStoryPlayerViewModel; - encounter: RuntimeStoryEncounterViewModel | null; - companions: RuntimeStoryCompanionViewModel[]; - availableOptions: RuntimeStoryOptionView[]; - status: RuntimeStoryStatusViewModel; -}; - -export type RuntimeStoryPresentation = { - actionText: string; - resultText: string; - storyText: string; - options: RuntimeStoryOptionView[]; - toast?: string | null; - battle?: RuntimeBattlePresentation | null; -}; - -export type RuntimeStoryPatch = - | { - type: 'story_history_append'; - actionText: string; - resultText: string; - } - | { - type: 'npc_affinity_changed'; - npcId: string; - previousAffinity: number; - nextAffinity: number; - } - | { - type: 'battle_resolved'; - functionId: string; - targetId?: string; - damageDealt?: number; - damageTaken?: number; - outcome: 'ongoing' | 'victory' | 'spar_complete' | 'escaped'; - } - | { - type: 'status_changed'; - inBattle: boolean; - npcInteractionActive: boolean; - currentNpcBattleMode: 'fight' | 'spar' | null; - currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null; - } - | { - type: 'encounter_changed'; - encounterId: string | null; - }; - -export type RuntimeStoryActionRequest = - RuntimeActionRequest & { - snapshot?: SavedGameSnapshotInput; - }; - -export type RuntimeStoryStateRequest< - TSnapshotGameState = JsonObject, - TSnapshotCurrentStory = JsonObject, -> = { - sessionId: string; - clientVersion?: number; - snapshot?: SavedGameSnapshotInput< - TSnapshotGameState, - string, - TSnapshotCurrentStory - >; -}; - -export type RuntimeStoryActionResponse< - TSnapshotGameState = JsonObject, - TSnapshotCurrentStory = JsonObject, -> = RuntimeActionResponse< - RuntimeStoryViewModel, - RuntimeStoryPresentation, - RuntimeStoryPatch -> & { - snapshot: SavedGameSnapshot< - TSnapshotGameState, - string, - TSnapshotCurrentStory - >; -}; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b219972c..e6d6be73 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,13 +1,19 @@ export * from './assets/qwenSprite'; export * from './contracts/auth'; export * from './contracts/common'; +export type * from './contracts/customWorldAgent'; export * from './contracts/rpgAgentActions'; export * from './contracts/rpgAgentAnchors'; +export * from './contracts/rpgAgentDraft'; export * from './contracts/rpgAgentSession'; +export * from './contracts/rpgCreationFixtures'; export * from './contracts/rpgCreationPreview'; export * from './contracts/rpgCreationWorkSummary'; +export * from './contracts/rpgRuntimeChat'; +export * from './contracts/rpgRuntimeQuestAssist'; +export * from './contracts/rpgRuntimeStoryAction'; +export * from './contracts/rpgRuntimeStoryState'; export * from './contracts/runtime'; -export * from './contracts/story'; export * from './http'; export * from './llm/narrativeLanguage'; export * from './llm/parsers'; diff --git a/server-node/src/app.test.ts b/server-node/src/app.test.ts index 548d5e7e..db79d426 100644 --- a/server-node/src/app.test.ts +++ b/server-node/src/app.test.ts @@ -208,7 +208,10 @@ async function authEntry(baseUrl: string, username: string, password: string) { username: string; }; }; - const refreshCookie = response.headers.get('set-cookie'); + const refreshCookie = buildCookieHeader( + response.headers.get('set-cookie'), + 'genarrative_refresh_session', + ); assert.equal(response.status, 200); assert.ok(payload.token); @@ -263,7 +266,10 @@ async function phoneLogin(baseUrl: string, phone: string, code = '123456') { wechatBound: boolean; }; }; - const refreshCookie = response.headers.get('set-cookie'); + const refreshCookie = buildCookieHeader( + response.headers.get('set-cookie'), + 'genarrative_refresh_session', + ); assert.equal(response.status, 200); assert.ok(payload.token); @@ -449,6 +455,191 @@ async function createObjectRefiningCustomWorldAgentSession(params: { return session; } +async function markAgentSessionPublishReady(params: { + context: TestAppContext; + userId: string; + sessionId: string; +}) { + const snapshot = await params.context.customWorldAgentOrchestrator.getSessionSnapshot( + params.userId, + params.sessionId, + ); + const draftProfile = snapshot?.draftProfile as Record | null; + const playableNpcs = Array.isArray(draftProfile?.playableNpcs) + ? (draftProfile?.playableNpcs as Array>) + : []; + const storyNpcs = Array.isArray(draftProfile?.storyNpcs) + ? (draftProfile?.storyNpcs as Array>) + : []; + const landmarks = Array.isArray(draftProfile?.landmarks) + ? (draftProfile?.landmarks as Array>) + : []; + const sceneChapters = Array.isArray(draftProfile?.sceneChapters) + ? (draftProfile?.sceneChapters as Array>) + : []; + const camp = + draftProfile?.camp && typeof draftProfile.camp === 'object' + ? (draftProfile.camp as Record) + : null; + const firstPlayableRoleId = + typeof playableNpcs[0]?.id === 'string' && playableNpcs[0]?.id.trim() + ? playableNpcs[0].id.trim() + : null; + const firstStoryRoleId = + typeof storyNpcs[0]?.id === 'string' && storyNpcs[0]?.id.trim() + ? storyNpcs[0].id.trim() + : firstPlayableRoleId; + + assert.ok(snapshot); + assert.ok(draftProfile); + assert.ok(playableNpcs.length > 0); + assert.ok(storyNpcs.length > 0); + assert.ok(landmarks.length > 0); + assert.ok(sceneChapters.length > 0); + assert.ok(firstStoryRoleId); + + await params.context.customWorldAgentSessions.replaceDerivedState( + params.userId, + params.sessionId, + { + stage: 'ready_to_publish', + qualityFindings: [], + draftProfile: { + ...draftProfile, + chapters: + Array.isArray(draftProfile.chapters) && draftProfile.chapters.length > 0 + ? draftProfile.chapters + : [{ id: 'chapter-main-1', title: '主线第一章' }], + camp: { + ...(camp ?? {}), + id: + typeof camp?.id === 'string' && camp.id.trim() + ? camp.id.trim() + : 'camp-home', + name: + typeof camp?.name === 'string' && camp.name.trim() + ? camp.name.trim() + : '归潮营地', + description: + typeof camp?.description === 'string' && camp.description.trim() + ? camp.description.trim() + : '可供玩家整理线索的临时据点。', + imageSrc: + typeof camp?.imageSrc === 'string' && camp.imageSrc.trim() + ? camp.imageSrc.trim() + : '/generated/camp/publish-ready.png', + generatedSceneAssetId: + typeof camp?.generatedSceneAssetId === 'string' && + camp.generatedSceneAssetId.trim() + ? camp.generatedSceneAssetId.trim() + : 'scene-camp-publish-ready', + generatedScenePrompt: + typeof camp?.generatedScenePrompt === 'string' && + camp.generatedScenePrompt.trim() + ? camp.generatedScenePrompt.trim() + : '潮雾营地发布正式图', + generatedSceneModel: + typeof camp?.generatedSceneModel === 'string' && + camp.generatedSceneModel.trim() + ? camp.generatedSceneModel.trim() + : 'test-scene-model', + }, + playableNpcs: playableNpcs.map((entry, index) => ({ + ...entry, + imageSrc: + typeof entry.imageSrc === 'string' && entry.imageSrc.trim() + ? entry.imageSrc.trim() + : `/generated/playable/publish-ready-${index + 1}.png`, + generatedVisualAssetId: + typeof entry.generatedVisualAssetId === 'string' && + entry.generatedVisualAssetId.trim() + ? entry.generatedVisualAssetId.trim() + : `visual-playable-publish-${index + 1}`, + generatedAnimationSetId: + typeof entry.generatedAnimationSetId === 'string' && + entry.generatedAnimationSetId.trim() + ? entry.generatedAnimationSetId.trim() + : `anim-playable-publish-${index + 1}`, + })), + storyNpcs: storyNpcs.map((entry, index) => ({ + ...entry, + imageSrc: + typeof entry.imageSrc === 'string' && entry.imageSrc.trim() + ? entry.imageSrc.trim() + : `/generated/story/publish-ready-${index + 1}.png`, + generatedVisualAssetId: + typeof entry.generatedVisualAssetId === 'string' && + entry.generatedVisualAssetId.trim() + ? entry.generatedVisualAssetId.trim() + : `visual-story-publish-${index + 1}`, + generatedAnimationSetId: + typeof entry.generatedAnimationSetId === 'string' && + entry.generatedAnimationSetId.trim() + ? entry.generatedAnimationSetId.trim() + : `anim-story-publish-${index + 1}`, + })), + landmarks: landmarks.map((entry, index) => ({ + ...entry, + imageSrc: + typeof entry.imageSrc === 'string' && entry.imageSrc.trim() + ? entry.imageSrc.trim() + : `/generated/landmark/publish-ready-${index + 1}.png`, + generatedSceneAssetId: + typeof entry.generatedSceneAssetId === 'string' && + entry.generatedSceneAssetId.trim() + ? entry.generatedSceneAssetId.trim() + : `scene-landmark-publish-${index + 1}`, + generatedScenePrompt: + typeof entry.generatedScenePrompt === 'string' && + entry.generatedScenePrompt.trim() + ? entry.generatedScenePrompt.trim() + : `地点 ${typeof entry.name === 'string' ? entry.name : index + 1} 的正式场景图`, + generatedSceneModel: + typeof entry.generatedSceneModel === 'string' && + entry.generatedSceneModel.trim() + ? entry.generatedSceneModel.trim() + : 'test-scene-model', + })), + sceneChapters: sceneChapters.map((chapter, chapterIndex) => { + const acts = Array.isArray(chapter.acts) + ? (chapter.acts as Array>) + : []; + + return { + ...chapter, + linkedThreadIds: + Array.isArray(chapter.linkedThreadIds) && + chapter.linkedThreadIds.length > 0 + ? chapter.linkedThreadIds + : ['thread-publish-ready'], + acts: acts.map((act, actIndex) => ({ + ...act, + encounterNpcIds: + Array.isArray(act.encounterNpcIds) && act.encounterNpcIds.length > 0 + ? act.encounterNpcIds + : [firstStoryRoleId], + primaryNpcId: + typeof act.primaryNpcId === 'string' && act.primaryNpcId.trim() + ? act.primaryNpcId.trim() + : firstStoryRoleId, + backgroundImageSrc: + typeof act.backgroundImageSrc === 'string' && + act.backgroundImageSrc.trim() + ? act.backgroundImageSrc.trim() + : `/generated/scene/publish-ready-${chapterIndex + 1}-${actIndex + 1}.png`, + backgroundAssetId: + typeof act.backgroundAssetId === 'string' && + act.backgroundAssetId.trim() + ? act.backgroundAssetId.trim() + : `scene-act-publish-${chapterIndex + 1}-${actIndex + 1}`, + })), + }; + }), + }, + }, + ); +} + function parseRedirectHash(location: string) { const url = new URL(location, 'http://127.0.0.1'); return new URLSearchParams( @@ -456,6 +647,18 @@ function parseRedirectHash(location: string) { ); } +function readCookieValue(cookieHeader: string, cookieName: string) { + const match = cookieHeader.match( + new RegExp(`${cookieName}=([^;,\r\n]+)`, 'u'), + ); + return match?.[1] ? decodeURIComponent(match[1]) : ''; +} + +function buildCookieHeader(cookieHeader: string | null | undefined, cookieName: string) { + const value = readCookieValue(cookieHeader || '', cookieName); + return value ? `${cookieName}=${encodeURIComponent(value)}` : ''; +} + async function startWechatMockFlow(baseUrl: string, redirectPath = '/') { const startResponse = await httpRequest( `${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent(redirectPath)}`, @@ -473,18 +676,10 @@ async function startWechatMockFlow(baseUrl: string, redirectPath = '/') { assert.ok(location); const hash = parseRedirectHash(location); const setCookieHeader = callbackResponse.headers.get('set-cookie') || ''; - const accessCookie = setCookieHeader - .split(',') - .map((entry) => entry.trim()) - .find((entry) => entry.startsWith('genarrative_access_session=')); - const token = - accessCookie - ?.split(';')[0] - ?.split('=') - .slice(1) - .join('=') - .trim() || ''; - + const token = readCookieValue( + setCookieHeader, + 'genarrative_access_session', + ); assert.ok(token); return { @@ -1552,7 +1747,10 @@ test('logout-all revokes all refresh sessions and invalidates existing access to assert.equal(refreshResponse.status, 200); const entryB = { token: refreshPayload.token, - refreshCookie: refreshResponse.headers.get('set-cookie') || '', + refreshCookie: buildCookieHeader( + refreshResponse.headers.get('set-cookie'), + 'genarrative_refresh_session', + ), }; const logoutAllResponse = await httpRequest( @@ -1623,7 +1821,7 @@ test('error responses share one structure and preserve request ids', async () => assert.equal(response.status, 401); assert.equal(payload.error.code, 'UNAUTHORIZED'); - assert.equal(payload.error.message, '缺少 Authorization Bearer Token'); + assert.equal(payload.error.message, '缺少登录凭证'); assert.equal(payload.meta.requestId, requestId); assert.equal(payload.meta.apiVersion, '2026-04-08'); assert.equal(payload.meta.routeVersion, '2026-04-08'); @@ -2895,6 +3093,98 @@ test('custom world agent draft_foundation action generates draft cards and card ); }); +test('custom world agent stream message returns enriched session payload over sse', async () => { + await withTestServer( + 'custom-world-agent-stream-session', + async ({ baseUrl, context }) => { + installTestCustomWorldAgentSingleTurnLlm(context); + const entry = await authEntry(baseUrl, 'cw_agent_stream', 'secret123'); + const readySession = await createReadyCustomWorldAgentSession({ + baseUrl, + token: entry.token, + }); + + const foundationResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/actions`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + action: 'draft_foundation', + }), + }), + ); + const foundationPayload = (await foundationResponse.json()) as { + operation: { + operationId: string; + }; + }; + + assert.equal(foundationResponse.status, 200); + + await waitForCustomWorldAgentOperation({ + baseUrl, + token: entry.token, + sessionId: readySession.sessionId, + operationId: foundationPayload.operation.operationId, + expectedStatus: 'completed', + }); + + const streamResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/messages/stream`, + withBearer(entry.token, { + method: 'POST', + headers: { + Accept: 'text/event-stream', + }, + body: JSON.stringify({ + clientMessageId: 'stream-client-1', + text: '把守灯会的压力再收紧一些,玩家与沈砺的旧案关系也再明显一点。', + focusCardId: null, + selectedCardIds: [], + }), + }), + ); + const streamText = await streamResponse.text(); + const sessionEventMatch = streamText.match(/event: session\s+data: (\{.*\})/u); + + assert.equal(streamResponse.status, 200); + assert.match( + streamResponse.headers.get('content-type') ?? '', + /text\/event-stream/u, + ); + assert.match(streamText, /event: reply_delta/u); + assert.match(streamText, /event: session/u); + assert.match(streamText, /event: done/u); + assert.ok(sessionEventMatch?.[1]); + + const sessionEvent = JSON.parse(sessionEventMatch![1]) as { + session: { + stage: string; + supportedActions?: Array<{ action: string; enabled: boolean }>; + resultPreview?: { + source: string; + preview: { name?: string }; + } | null; + }; + }; + + assert.equal(sessionEvent.session.stage, 'object_refining'); + assert.equal( + sessionEvent.session.supportedActions?.some( + (entry) => + entry.action === 'update_draft_card' && entry.enabled === true, + ), + true, + ); + assert.equal( + sessionEvent.session.resultPreview?.source, + 'session_preview', + ); + assert.ok(sessionEvent.session.resultPreview?.preview?.name); + }, + ); +}); + test('custom world agent draft_foundation action rejects not-ready sessions over http', async () => { await withTestServer( 'custom-world-agent-phase3-http-not-ready', @@ -3197,6 +3487,129 @@ test('custom world agent sync_result_profile action writes result snapshot back ); }); +test('library publish for agent-backed draft returns explicit blocker message when Phase4 gate is not ready', async () => { + await withTestServer( + 'custom-world-library-agent-publish-blocked', + async ({ baseUrl, context }) => { + installTestCustomWorldAgentSingleTurnLlm(context); + const entry = await authEntry( + baseUrl, + 'cw_library_agent_blocked', + 'secret123', + ); + const session = await createObjectRefiningCustomWorldAgentSession({ + baseUrl, + token: entry.token, + }); + const profileId = `agent-draft-${session.sessionId}`; + + const publishResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library/${encodeURIComponent(profileId)}/publish`, + withBearer(entry.token, { + method: 'POST', + }), + ); + const publishPayload = (await publishResponse.json()) as { + error: { + code: string; + message: string; + }; + }; + const sessionAfterPublishAttempt = + await context.customWorldAgentOrchestrator.getSessionSnapshot( + entry.user.id, + session.sessionId, + ); + + assert.equal(publishResponse.status, 409); + assert.equal(publishPayload.error.code, 'CONFLICT'); + assert.match( + publishPayload.error.message, + /当前世界仍有 \d+ 个 blocker/u, + ); + assert.match( + publishPayload.error.message, + /缺少正式主图|缺少正式场景图|主线第一幕/u, + ); + assert.notEqual(sessionAfterPublishAttempt?.stage, 'published'); + }, + ); +}); + +test('library publish for agent-backed draft reuses Phase4 gate and syncs session into published stage', async () => { + await withTestServer( + 'custom-world-library-agent-publish-success', + async ({ baseUrl, context }) => { + installTestCustomWorldAgentSingleTurnLlm(context); + const entry = await authEntry( + baseUrl, + 'cw_library_agent_success', + 'secret123', + ); + const session = await createObjectRefiningCustomWorldAgentSession({ + baseUrl, + token: entry.token, + }); + const profileId = `agent-draft-${session.sessionId}`; + + await markAgentSessionPublishReady({ + context, + userId: entry.user.id, + sessionId: session.sessionId, + }); + + const publishResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library/${encodeURIComponent(profileId)}/publish`, + withBearer(entry.token, { + method: 'POST', + }), + ); + const publishPayload = (await publishResponse.json()) as { + entry: { + profileId: string; + visibility: 'draft' | 'published'; + }; + }; + const libraryResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library`, + withBearer(entry.token), + ); + const libraryPayload = (await libraryResponse.json()) as { + entries: Array<{ + profileId: string; + visibility: 'draft' | 'published'; + }>; + }; + const sessionAfterPublish = + await context.customWorldAgentOrchestrator.getSessionSnapshot( + entry.user.id, + session.sessionId, + ); + + assert.equal(publishResponse.status, 200); + assert.equal(publishPayload.entry.profileId, profileId); + assert.equal(publishPayload.entry.visibility, 'published'); + assert.equal(libraryResponse.status, 200); + assert.equal( + libraryPayload.entries.find((item) => item.profileId === profileId) + ?.visibility, + 'published', + ); + assert.equal(sessionAfterPublish?.stage, 'published'); + assert.equal(sessionAfterPublish?.resultPreview?.publishReady, true); + assert.equal(sessionAfterPublish?.resultPreview?.canEnterWorld, true); + assert.deepEqual(sessionAfterPublish?.resultPreview?.blockers ?? [], []); + assert.ok( + sessionAfterPublish?.messages.some( + (message) => + message.kind === 'action_result' && + message.text.includes('已正式发布'), + ), + ); + }, + ); +}); + test('custom world agent generate_characters action appends character cards over http', async () => { await withTestServer( 'custom-world-agent-phase4-generate-characters-http', diff --git a/server-node/src/app.ts b/server-node/src/app.ts index 2227b813..ddd24152 100644 --- a/server-node/src/app.ts +++ b/server-node/src/app.ts @@ -9,9 +9,13 @@ import { requestIdMiddleware } from './middleware/requestId.js'; import { responseEnvelopeMiddleware } from './middleware/responseEnvelope.js'; import { createCharacterAssetRoutes } from './modules/assets/characterAssetRoutes.js'; import { createEditorRoutes } from './modules/editor/editorRoutes.js'; -import { createStoryActionRoutes } from './modules/story/storyActionRoutes.js'; import { createAuthRoutes } from './routes/authRoutes.js'; -import { createRuntimeRoutes } from './routes/runtimeRoutes.js'; +import { createRpgEntrySaveRoutes } from './routes/rpg-entry/rpgEntrySaveRoutes.js'; +import { createRpgWorldLibraryRoutes } from './routes/rpg-entry/rpgWorldLibraryRoutes.js'; +import { createRpgProfileRoutes } from './routes/rpg-profile/rpgProfileRoutes.js'; +import { createRpgRuntimeAiAssistRoutes } from './routes/rpg-runtime/rpgRuntimeAiAssistRoutes.js'; +import { createRpgRuntimeStoryRoutes } from './routes/rpg-runtime/rpgRuntimeStoryRoutes.js'; +import { createCustomWorldAgentRoutes } from './routes/customWorldAgent.js'; function matchesRoutePrefix( request: express.Request, @@ -118,6 +122,33 @@ export function createApp(context: AppContext) { createCharacterAssetRoutes(context.config, context.llmClient), ), ); + app.use( + '/api', + scopeToPrefixes( + ['/runtime/profile', '/profile', '/runtime/settings'], + withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.profile.api' }), + ), + createRpgProfileRoutes(context), + ); + app.use( + '/api', + scopeToPrefixes( + ['/runtime/save', '/runtime/profile/save-archives', '/profile/save-archives'], + withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.entry.save.api' }), + ), + createRpgEntrySaveRoutes(context), + ); + app.use( + '/api', + scopeToPrefixes( + ['/runtime/custom-world-gallery', '/runtime/custom-world/works', '/runtime/custom-world-library'], + withRouteMeta({ + routeVersion: '2026-04-21', + operation: 'rpg.entry.worldLibrary.api', + }), + ), + createRpgWorldLibraryRoutes(context), + ); app.use( '/api/auth', withRouteMeta({ routeVersion: '2026-04-08' }), @@ -125,13 +156,61 @@ export function createApp(context: AppContext) { ); app.use( '/api/runtime/story', - withRouteMeta({ routeVersion: '2026-04-08' }), - createStoryActionRoutes(context), + withRouteMeta({ routeVersion: '2026-04-21' }), + createRpgRuntimeStoryRoutes(context), + ); + app.use( + scopeToPrefixes( + [ + '/llm/chat/completions', + '/custom-world/cover-image', + '/custom-world/cover-upload', + '/custom-world/scene-image', + '/custom-world/entity', + '/custom-world/scene-npc', + '/runtime/custom-world/entity', + '/runtime/custom-world/scene-npc', + '/runtime/custom-world/profile', + '/runtime/story/initial', + '/runtime/story/continue', + '/runtime/chat', + '/runtime/items', + '/runtime/quests', + '/ws/health', + ], + withRouteMeta({ + routeVersion: '2026-04-21', + operation: 'rpg.runtime.aiAssist.api', + }), + ), ); app.use( '/api', - withRouteMeta({ routeVersion: '2026-04-08' }), - createRuntimeRoutes(context), + scopeToPrefixes( + [ + '/llm/chat/completions', + '/custom-world/cover-image', + '/custom-world/cover-upload', + '/custom-world/scene-image', + '/custom-world/entity', + '/custom-world/scene-npc', + '/runtime/custom-world/entity', + '/runtime/custom-world/scene-npc', + '/runtime/custom-world/profile', + '/runtime/story/initial', + '/runtime/story/continue', + '/runtime/chat', + '/runtime/items', + '/runtime/quests', + '/ws/health', + ], + createRpgRuntimeAiAssistRoutes(context), + ), + ); + app.use( + '/api/runtime/custom-world/agent', + withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.creation.agent.api' }), + createCustomWorldAgentRoutes(context), ); app.use( express.static(context.config.publicDir, { diff --git a/server-node/src/auth/refreshSessionCookie.ts b/server-node/src/auth/refreshSessionCookie.ts index ad51d8f0..1da7a543 100644 --- a/server-node/src/auth/refreshSessionCookie.ts +++ b/server-node/src/auth/refreshSessionCookie.ts @@ -32,6 +32,21 @@ function buildCookieParts( return parts.join('; '); } +function appendSetCookieHeader(response: Response, cookieValue: string) { + const currentHeader = response.getHeader('Set-Cookie'); + if (!currentHeader) { + response.setHeader('Set-Cookie', cookieValue); + return; + } + + if (Array.isArray(currentHeader)) { + response.setHeader('Set-Cookie', [...currentHeader, cookieValue]); + return; + } + + response.setHeader('Set-Cookie', [String(currentHeader), cookieValue]); +} + export function hashRefreshSessionToken(token: string) { return crypto.createHash('sha256').update(token).digest('hex'); } @@ -46,8 +61,8 @@ export function setRefreshSessionCookie( token: string, maxAgeSeconds: number, ) { - response.setHeader( - 'Set-Cookie', + appendSetCookieHeader( + response, buildCookieParts(config, token, { maxAgeSeconds, }), @@ -55,8 +70,8 @@ export function setRefreshSessionCookie( } export function clearRefreshSessionCookie(response: Response, config: AppConfig) { - response.setHeader( - 'Set-Cookie', + appendSetCookieHeader( + response, buildCookieParts(config, '', { maxAgeSeconds: 0, }), diff --git a/server-node/src/context.ts b/server-node/src/context.ts index e978ac56..01b0503d 100644 --- a/server-node/src/context.ts +++ b/server-node/src/context.ts @@ -5,6 +5,13 @@ import type { AppDatabase } from './db.js'; import { AuthAuditLogRepository } from './repositories/authAuditLogRepository.js'; import { AuthIdentityRepository } from './repositories/authIdentityRepository.js'; import { AuthRiskBlockRepository } from './repositories/authRiskBlockRepository.js'; +import type { RpgAgentSessionRepository } from './repositories/RpgAgentSessionRepository.js'; +import type { RpgSaveArchiveRepository } from './repositories/rpg-entry/RpgSaveArchiveRepository.js'; +import type { RpgWorldLibraryRepository } from './repositories/rpg-entry/RpgWorldLibraryRepository.js'; +import type { RpgBrowseHistoryRepository } from './repositories/rpg-profile/RpgBrowseHistoryRepository.js'; +import type { RpgProfileDashboardRepository } from './repositories/rpg-profile/RpgProfileDashboardRepository.js'; +import type { RpgRuntimeSnapshotRepository } from './repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js'; +import type { RpgWorldProfileRepository } from './repositories/RpgWorldProfileRepository.js'; import { RuntimeRepository } from './repositories/runtimeRepository.js'; import { SmsAuthEventRepository } from './repositories/smsAuthEventRepository.js'; import { UserRepository } from './repositories/userRepository.js'; @@ -13,6 +20,7 @@ import { CaptchaChallengeStore } from './services/captchaChallengeStore.js'; import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js'; import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js'; import { UpstreamLlmClient } from './services/llmClient.js'; +import type { RpgWorldWorkSummaryService } from './services/RpgWorldWorkSummaryService.js'; import type { SmsVerificationService } from './services/smsVerificationService.js'; import type { WechatAuthService } from './services/wechatAuthService.js'; import { WechatAuthStateStore } from './services/wechatAuthStateStore.js'; @@ -27,10 +35,18 @@ export type AppContext = { authRiskBlockRepository: AuthRiskBlockRepository; smsAuthEventRepository: SmsAuthEventRepository; userSessionRepository: UserSessionRepository; + rpgAgentSessionRepository: RpgAgentSessionRepository; + rpgWorldProfileRepository: RpgWorldProfileRepository; + rpgProfileDashboardRepository: RpgProfileDashboardRepository; + rpgBrowseHistoryRepository: RpgBrowseHistoryRepository; + rpgSaveArchiveRepository: RpgSaveArchiveRepository; + rpgWorldLibraryRepository: RpgWorldLibraryRepository; + rpgRuntimeSnapshotRepository: RpgRuntimeSnapshotRepository; runtimeRepository: RuntimeRepository; llmClient: UpstreamLlmClient; customWorldAgentSessions: CustomWorldAgentSessionStore; customWorldAgentOrchestrator: CustomWorldAgentOrchestrator; + rpgWorldWorkSummaryService: RpgWorldWorkSummaryService; smsVerificationService: SmsVerificationService; wechatAuthService: WechatAuthService; wechatAuthStates: WechatAuthStateStore; diff --git a/server-node/src/modules/ai/chatOrchestrator.ts b/server-node/src/modules/ai/chatOrchestrator.ts index ab286506..124cb35d 100644 --- a/server-node/src/modules/ai/chatOrchestrator.ts +++ b/server-node/src/modules/ai/chatOrchestrator.ts @@ -9,7 +9,7 @@ import type { type NpcChatTurnCompletionDirective, NpcChatTurnRequest, NpcRecruitDialogueRequest, -} from '../../../../packages/shared/src/contracts/story.js'; +} from '../../../../packages/shared/src/contracts/rpgRuntimeChat.js'; import { parseLineListContent } from '../../../../packages/shared/src/llm/parsers.js'; import { prepareEventStreamResponse } from '../../http.js'; import type { UpstreamLlmClient } from '../../services/llmClient.js'; diff --git a/server-node/src/modules/ai/orchestrator.test.ts b/server-node/src/modules/ai/orchestrator.test.ts index e947f1b0..8bea50ce 100644 --- a/server-node/src/modules/ai/orchestrator.test.ts +++ b/server-node/src/modules/ai/orchestrator.test.ts @@ -4,7 +4,7 @@ import test from 'node:test'; import type { CharacterChatSuggestionsRequest, NpcChatTurnRequest, -} from '../../../../packages/shared/src/contracts/story.js'; +} from '../../../../packages/shared/src/contracts/rpgRuntimeChat.js'; import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js'; import { generateCharacterChatSuggestionsFromOrchestrator, diff --git a/server-node/src/modules/combat/combatResolutionService.ts b/server-node/src/modules/combat/combatResolutionService.ts index 42d864d2..a2117843 100644 --- a/server-node/src/modules/combat/combatResolutionService.ts +++ b/server-node/src/modules/combat/combatResolutionService.ts @@ -1,8 +1,10 @@ import type { RuntimeBattlePresentation, - RuntimeStoryChoicePayload, RuntimeStoryPatch, -} from '../../../../packages/shared/src/contracts/story.js'; +} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; +import type { + RuntimeStoryChoicePayload, +} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryAction.js'; import { buildInventoryUseResultText, incrementGameRuntimeStats, @@ -26,7 +28,7 @@ import { getPlayerSkillCooldowns, setEncounterNpcState, type RuntimeSession, -} from '../story/runtimeSession.js'; +} from '../rpg-runtime-story/RpgRuntimeSessionPrimitives.js'; type CombatActionConfig = { actionText: string; diff --git a/server-node/src/modules/custom-world/runtime-profile/buildAttributeSchema.ts b/server-node/src/modules/custom-world/runtime-profile/buildAttributeSchema.ts new file mode 100644 index 00000000..0301a2fb --- /dev/null +++ b/server-node/src/modules/custom-world/runtime-profile/buildAttributeSchema.ts @@ -0,0 +1,365 @@ +import type { + AttributeVector, + CustomWorldNpc, + CustomWorldPlayableNpc, + RoleAttributeProfile, + WorldAttributeSchema, + WorldAttributeSlot, + WorldType, +} from '../runtimeTypes.js'; +import { inferWorldTypeFromSetting } from './creatorIntentBridge.js'; +import { slugify } from './normalizeShared.js'; + +/** + * 工作包 G: + * 把 attribute schema 构建和角色属性画像编译从主 runtime compiler 中抽离, + * 让结果预览编译、世界基础 profile 归一和角色属性推导有清晰边界。 + */ + +const WORLD_ATTRIBUTE_SLOT_IDS = [ + 'axis_a', + 'axis_b', + 'axis_c', + 'axis_d', + 'axis_e', + 'axis_f', +] as const; + +const AXIS_KEYWORD_RULES: Array<{ + slotId: string; + patterns: RegExp[]; + weight: number; +}> = [ + { slotId: 'axis_a', patterns: [/骨|甲|壳|岩|重|守|镇|顶|硬|锋|体/u], weight: 16 }, + { slotId: 'axis_b', patterns: [/身法|迅|影|风|闪|游|机动|追|步|轻灵/u], weight: 16 }, + { slotId: 'axis_c', patterns: [/识|眼|谋|算|阵|符|术|察|局|禁制/u], weight: 16 }, + { slotId: 'axis_d', patterns: [/心|焰|胆|威|压|怒|决|破|强推|意志/u], weight: 16 }, + { slotId: 'axis_e', patterns: [/缘|契|情|盟|商|医|助|信|交|誓/u], weight: 16 }, + { slotId: 'axis_f', patterns: [/息|稳|续|守|调|回|养|持久|回复|韧/u], weight: 16 }, +]; + +export function buildTemplateWorldAttributeSchema( + worldType: Exclude, +) { + const common = { + schemaVersion: 1, + generatedFrom: + worldType === 'XIANXIA' + ? { + worldType: 'XIANXIA' as const, + worldName: '仙侠', + settingSummary: '灵潮、宗门、禁制、秘境与道途交织。', + tone: '空灵、危险、带着灾变与大道压迫。', + conflictCore: '在裂变与因果之间稳住自我与道途。', + } + : { + worldType: 'WUXIA' as const, + worldName: '武侠', + settingSummary: '江湖、门派、旧案与人情纠葛并存。', + tone: '克制、紧张、讲究局势与心气。', + conflictCore: '在人情、威压与旧案之间立住自身。', + }, + }; + + if (worldType === 'XIANXIA') { + return { + id: 'schema:xianxia:v1', + worldId: 'XIANXIA', + schemaName: '灵界六轴', + ...common, + slots: [ + { + slotId: 'axis_a', + name: '道骨', + definition: '承载道压与高强度冲击的底子。', + positiveSignals: ['承压', '根基稳', '扛得住'], + negativeSignals: ['根基浅', '易溃', '承载不足'], + combatUseText: '扛住灵压、正面承受高强度对撞。', + socialUseText: '让人感到根基扎实,值得托付重事。', + explorationUseText: '承受秘境、禁制与裂隙带来的压迫。', + }, + { + slotId: 'axis_b', + name: '灵行', + definition: '位移、御空、转场、抢占天时地利的能力。', + positiveSignals: ['位移', '御空', '机动'], + negativeSignals: ['迟滞', '失位', '转场慢'], + combatUseText: '抢位、御空、快速重整战场位置。', + socialUseText: '反应轻快,擅长顺势接住局面的变化。', + explorationUseText: '穿越高危地形、裂隙、云海与复杂禁区。', + }, + { + slotId: 'axis_c', + name: '识海', + definition: '解析禁制、洞察因果、识破虚实的能力。', + positiveSignals: ['洞察', '解构', '看破'], + negativeSignals: ['迷失', '误判', '看不清'], + combatUseText: '识破术理、找出因果节点与破绽。', + socialUseText: '更容易辨认真话、虚言与隐藏动机。', + explorationUseText: '解读阵纹、禁制、旧史与环境异象。', + }, + { + slotId: 'axis_d', + name: '劫纹', + definition: '在高危变化中强行推进、改写局势的能力。', + positiveSignals: ['强推', '决断', '逆转'], + negativeSignals: ['畏缩', '迟疑', '不敢碰变局'], + combatUseText: '在高压窗口里压上去,逼出变化与突破。', + socialUseText: '在关键谈判中拍板,推动他人表态。', + explorationUseText: '面对异变与风险时敢于推进关键节点。', + }, + { + slotId: 'axis_e', + name: '心契', + definition: '与他者、器物、灵兽、誓约建立共鸣的能力。', + positiveSignals: ['共鸣', '结契', '安抚'], + negativeSignals: ['隔阂', '生硬', '难以共振'], + combatUseText: '与器物、灵兽、同伴形成协同与共鸣。', + socialUseText: '建立信任、誓约与更深层的关系连结。', + explorationUseText: '借由共鸣打开封印、回应遗物或安抚异兽。', + }, + { + slotId: 'axis_f', + name: '玄息', + definition: '循环灵息、稳住心神、让自身持续在线的能力。', + positiveSignals: ['稳态', '回转', '续航'], + negativeSignals: ['紊乱', '枯竭', '失衡'], + combatUseText: '维持灵息循环、拖住长线压力与消耗。', + socialUseText: '气息沉稳,不轻易乱阵脚或露出破绽。', + explorationUseText: '在漫长探索与灵潮侵蚀中维持可行动状态。', + }, + ] satisfies WorldAttributeSlot[], + } satisfies WorldAttributeSchema; + } + + return { + id: 'schema:wuxia:v1', + worldId: 'WUXIA', + schemaVersion: 1, + schemaName: '江湖六脉', + generatedFrom: common.generatedFrom, + slots: [ + { + slotId: 'axis_a', + name: '骨势', + definition: '扛压、顶冲、硬吃风险也不退的势头。', + positiveSignals: ['扛压', '硬桥硬马', '稳住正面'], + negativeSignals: ['虚浮', '怯退', '一碰就散'], + combatUseText: '顶住正面压力、换伤不退、撑住阵线。', + socialUseText: '在强压场面里不露怯,给人可靠或强硬之感。', + explorationUseText: '穿越险路、硬顶机关、承受高压环境。', + }, + { + slotId: 'axis_b', + name: '身法', + definition: '腾挪、抢位、换线、把握出手节奏的能力。', + positiveSignals: ['快', '轻灵', '抢位'], + negativeSignals: ['迟缓', '失位', '笨重'], + combatUseText: '切线换位、闪转腾挪、争夺先手。', + socialUseText: '应变快,擅长观察气口并顺势接话。', + explorationUseText: '攀越、潜入、追踪与复杂地形穿行。', + }, + { + slotId: 'axis_c', + name: '眼脉', + definition: '看破破绽、拆招、识局、看穿人心的能力。', + positiveSignals: ['识局', '洞察', '拆招'], + negativeSignals: ['迟钝', '误判', '看不透'], + combatUseText: '抓破绽、拆套路、找出最该切入的位置。', + socialUseText: '判断弦外之音、试探真假、识别来意。', + explorationUseText: '识破机关、辨认痕迹、看懂异状。', + }, + { + slotId: 'axis_d', + name: '心焰', + definition: '决断、压迫、胆气、在局面中立住自身意志的能力。', + positiveSignals: ['胆气', '决断', '压迫'], + negativeSignals: ['犹疑', '软弱', '易被动摇'], + combatUseText: '逼迫对手、强行推进、在关键时刻拍板。', + socialUseText: '立威、定调、在谈判里压住场子。', + explorationUseText: '在未知风险前保持决断,不被局势拖死。', + }, + { + slotId: 'axis_e', + name: '尘缘', + definition: '与人事、情面、承诺、牵引关系打交道的能力。', + positiveSignals: ['通人情', '会安抚', '懂交换'], + negativeSignals: ['生硬', '失礼', '不近人情'], + combatUseText: '借势协同、读懂同伴与对手的关系脉络。', + socialUseText: '安抚、求助、结盟、维系承诺与信任。', + explorationUseText: '从传闻、人脉和地方关系里打开线索。', + }, + { + slotId: 'axis_f', + name: '玄息', + definition: '调息、稳态、久战、把自身维持在可用状态的能力。', + positiveSignals: ['稳', '续战', '调息'], + negativeSignals: ['紊乱', '易崩', '续不上'], + combatUseText: '续战、回气、稳住节奏与状态。', + socialUseText: '遇事不乱,语气和姿态都更沉稳可信。', + explorationUseText: '长线跋涉、在恶劣环境下维持专注与状态。', + }, + ] satisfies WorldAttributeSlot[], + } satisfies WorldAttributeSchema; +} + +export function generateWorldAttributeSchema(input: { + worldName: string; + settingText: string; + summary: string; + tone: string; + playerGoal: string; +}) { + const inferredWorldType = inferWorldTypeFromSetting(input.settingText); + const template = buildTemplateWorldAttributeSchema( + inferredWorldType === 'XIANXIA' ? 'XIANXIA' : 'WUXIA', + ); + + return { + ...template, + id: `schema:custom:${slugify(input.worldName)}`, + worldId: `custom:${input.worldName}`, + generatedFrom: { + worldType: 'CUSTOM', + worldName: input.worldName, + settingSummary: input.summary, + tone: input.tone, + conflictCore: input.playerGoal, + }, + } satisfies WorldAttributeSchema; +} + +function normalizeAttributeValues( + values: AttributeVector, + slotIds: readonly string[], + targetTotal = 360, +) { + const positiveValues = slotIds.map((slotId) => Math.max(0, values[slotId] ?? 0)); + const rawTotal = positiveValues.reduce((sum, value) => sum + value, 0); + const normalized = + rawTotal > 0 + ? positiveValues.map((value) => (value / rawTotal) * targetTotal) + : slotIds.map(() => targetTotal / Math.max(slotIds.length, 1)); + const rounded = normalized.map((value) => Math.max(0, Math.min(100, Math.round(value)))); + return Object.fromEntries( + slotIds.map((slotId, index) => [slotId, rounded[index] ?? 0]), + ) as AttributeVector; +} + +function ensureRoleAttributeProfile( + profile: Partial | null | undefined, + schema: WorldAttributeSchema, + fallbackValues: AttributeVector, +): RoleAttributeProfile { + const slotIds = schema.slots.map((slot) => slot.slotId); + const values = normalizeAttributeValues( + { + ...fallbackValues, + ...(profile?.values ?? {}), + }, + slotIds, + ); + const sortedSlots = [...schema.slots] + .map((slot) => ({ + slot, + value: values[slot.slotId] ?? 0, + })) + .sort((left, right) => right.value - left.value); + + return { + schemaId: profile?.schemaId ?? schema.id, + values, + topTraits: sortedSlots.slice(0, 2).map((entry) => entry.slot.name), + hiddenTraits: profile?.hiddenTraits ? [...profile.hiddenTraits] : undefined, + evidence: + profile?.evidence?.length + ? [...profile.evidence] + : sortedSlots.slice(0, 3).map((entry) => ({ + slotId: entry.slot.slotId, + reason: `${entry.slot.name}在当前画像中最突出。`, + })), + }; +} + +function buildDefaultAxisVector( + overrides: Partial>, +) { + return WORLD_ATTRIBUTE_SLOT_IDS.reduce((result, slotId) => { + result[slotId] = overrides[slotId] ?? 0; + return result; + }, {}); +} + +function buildRoleAttributeProfileFromTexts(params: { + schema: WorldAttributeSchema; + textBlocks: Array; +}) { + const sourceText = params.textBlocks.filter(Boolean).join(' '); + const seed = buildDefaultAxisVector({ + axis_a: 58, + axis_b: 58, + axis_c: 58, + axis_d: 58, + axis_e: 58, + axis_f: 58, + }); + + AXIS_KEYWORD_RULES.forEach((rule) => { + const matches = rule.patterns.reduce( + (count, pattern) => count + (pattern.test(sourceText) ? 1 : 0), + 0, + ); + if (matches <= 0) { + return; + } + seed[rule.slotId] = (seed[rule.slotId] ?? 0) + rule.weight * matches; + }); + + return ensureRoleAttributeProfile( + { + schemaId: params.schema.id, + }, + params.schema, + seed, + ); +} + +export function buildCustomWorldPlayableNpcAttributeProfile( + npc: CustomWorldPlayableNpc, + schema: WorldAttributeSchema, +) { + return buildRoleAttributeProfileFromTexts({ + schema, + textBlocks: [ + npc.title, + npc.role, + npc.description, + npc.backstory, + npc.personality, + npc.motivation, + npc.combatStyle, + ...(npc.relationshipHooks ?? []), + ...(npc.tags ?? []), + ], + }); +} + +export function buildCustomWorldStoryNpcAttributeProfile( + npc: CustomWorldNpc, + schema: WorldAttributeSchema, +) { + return buildRoleAttributeProfileFromTexts({ + schema, + textBlocks: [ + npc.title, + npc.role, + npc.description, + npc.backstory, + npc.personality, + npc.motivation, + npc.combatStyle, + ...(npc.relationshipHooks ?? []), + ...(npc.tags ?? []), + ], + }); +} diff --git a/server-node/src/modules/custom-world/runtime-profile/buildCompiledProfile.ts b/server-node/src/modules/custom-world/runtime-profile/buildCompiledProfile.ts new file mode 100644 index 00000000..f9965b6f --- /dev/null +++ b/server-node/src/modules/custom-world/runtime-profile/buildCompiledProfile.ts @@ -0,0 +1,410 @@ +import type { + CustomWorldGenerationFramework, + CustomWorldProfile, +} from '../runtimeTypes.js'; +import { + buildCustomWorldPlayableNpcAttributeProfile, + buildCustomWorldStoryNpcAttributeProfile, + generateWorldAttributeSchema, +} from './buildAttributeSchema.js'; +import { + buildWorldName, + inferWorldTypeFromSetting, + normalizeWorldType, + normalizeCustomWorldCreatorIntent, + normalizeCustomWorldLockState, + resolveCustomWorldRuntimeIntentBridge, +} from './creatorIntentBridge.js'; +import { + buildFallbackCustomWorldCampScene, + normalizeCampOutline, + normalizeCampScene, +} from './normalizeCamp.js'; +import { + buildCustomWorldRawProfileLandmarksFromFramework, + normalizeLandmarkOutlineList, + normalizeLandmarks, +} from './normalizeLandmark.js'; +import { + buildCustomWorldRawProfileRolesFromFramework, + normalizeCustomWorldGenerationFrameworkRoles, + normalizePlayableNpcList, + normalizeStoryNpcList, +} from './normalizeRole.js'; +import { + buildDefaultCustomWorldCover, + MIN_CUSTOM_WORLD_LANDMARK_COUNT, + MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, + normalizeCustomWorldCover, + normalizeItemList, + normalizeTags, + PLAYABLE_TEMPLATE_CHARACTER_IDS, + slugify, + toRecordArray, + toText, +} from './normalizeShared.js'; +import { normalizeSceneChapterBlueprints } from './normalizeSceneChapter.js'; + +/** + * 工作包 G: + * 让 runtime profile 真正由“主编译入口 + 目录化 normalize/build 子模块”组成。 + */ + +function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile { + const templateWorldType = inferWorldTypeFromSetting(settingText); + const name = buildWorldName(settingText, templateWorldType); + const subtitle = '前路未明'; + const summary = settingText.trim() + ? `这个世界围绕“${settingText.trim().slice(0, 28)}”展开。` + : '一个仍待展开的独立世界正在成形。'; + const tone = '未知、紧绷、仍在展开'; + const playerGoal = '查清眼前局势的关键矛盾,并守住仍值得相信的人与事'; + const camp = buildFallbackCustomWorldCampScene({ + name, + summary, + tone, + playerGoal, + settingText: settingText.trim(), + }); + + return { + id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`, + settingText: settingText.trim(), + name, + subtitle, + summary, + tone, + playerGoal, + cover: buildDefaultCustomWorldCover([]), + templateWorldType, + compatibilityTemplateWorldType: templateWorldType, + majorFactions: [], + coreConflicts: [summary], + attributeSchema: generateWorldAttributeSchema({ + worldName: name, + settingText: settingText.trim(), + summary, + tone, + playerGoal, + }), + playableNpcs: [], + storyNpcs: [], + items: [], + camp, + landmarks: [], + themePack: null, + storyGraph: null, + creatorIntent: null, + anchorPack: null, + lockState: normalizeCustomWorldLockState(null), + generationMode: 'full', + generationStatus: 'complete', + ownedSettingLayers: null, + scenarioPackId: null, + campaignPackId: null, + }; +} + +export function normalizeCustomWorldGenerationFramework( + raw: unknown, + settingText: string, +): CustomWorldGenerationFramework { + const fallback = buildBaseCustomWorldProfile(settingText); + if (!raw || typeof raw !== 'object') { + return { + settingText: fallback.settingText, + name: fallback.name, + subtitle: fallback.subtitle, + summary: fallback.summary, + tone: fallback.tone, + playerGoal: fallback.playerGoal, + templateWorldType: fallback.templateWorldType, + compatibilityTemplateWorldType: + fallback.compatibilityTemplateWorldType ?? fallback.templateWorldType, + majorFactions: [], + coreConflicts: [fallback.summary], + camp: { + name: fallback.camp?.name ?? '归舍', + description: fallback.camp?.description ?? '', + dangerLevel: fallback.camp?.dangerLevel ?? 'low', + }, + playableNpcs: [], + storyNpcs: [], + landmarks: [], + }; + } + + const item = raw as Record; + const roleState = normalizeCustomWorldGenerationFrameworkRoles({ + raw: item, + fallback, + settingText, + }); + + return { + settingText: settingText.trim(), + name: roleState.name, + subtitle: toText(item.subtitle) || fallback.subtitle, + summary: toText(item.summary) || fallback.summary, + tone: toText(item.tone) || fallback.tone, + playerGoal: toText(item.playerGoal) || fallback.playerGoal, + templateWorldType: roleState.templateWorldType, + compatibilityTemplateWorldType: roleState.templateWorldType, + majorFactions: normalizeTags(item.majorFactions, []), + coreConflicts: normalizeTags(item.coreConflicts, [fallback.summary]), + camp: { + name: normalizeCampOutline(item.camp, roleState.campFallbackProfile).name, + description: normalizeCampOutline(item.camp, roleState.campFallbackProfile) + .description, + dangerLevel: normalizeCampOutline(item.camp, roleState.campFallbackProfile) + .dangerLevel, + }, + playableNpcs: roleState.playableNpcs, + storyNpcs: roleState.storyNpcs, + landmarks: normalizeLandmarkOutlineList(item.landmarks), + }; +} + +export function buildCustomWorldRawProfileFromFramework( + framework: CustomWorldGenerationFramework, +) { + return { + name: framework.name, + subtitle: framework.subtitle, + summary: framework.summary, + tone: framework.tone, + playerGoal: framework.playerGoal, + templateWorldType: framework.templateWorldType, + compatibilityTemplateWorldType: framework.compatibilityTemplateWorldType, + majorFactions: framework.majorFactions, + coreConflicts: framework.coreConflicts, + camp: { + name: framework.camp.name, + description: framework.camp.description, + dangerLevel: framework.camp.dangerLevel, + }, + ...buildCustomWorldRawProfileRolesFromFramework(framework), + landmarks: buildCustomWorldRawProfileLandmarksFromFramework(framework), + }; +} + +function pickCyclic(items: readonly T[], index: number, label: string): T { + const item = items[index % items.length]; + if (item === undefined) { + throw new Error(`Missing ${label}`); + } + return item; +} + +export function normalizeCustomWorldProfile( + raw: unknown, + settingText: string, +): CustomWorldProfile { + const fallback = buildBaseCustomWorldProfile(settingText); + if (!raw || typeof raw !== 'object') { + return fallback; + } + + const item = raw as Record; + const worldSignalText = [ + settingText, + toText(item.subtitle), + toText(item.summary), + toText(item.tone), + toText(item.playerGoal), + ].join(' '); + const templateWorldType = normalizeWorldType( + item.templateWorldType, + worldSignalText, + ); + const name = + toText(item.name) || buildWorldName(settingText, templateWorldType); + const summary = toText(item.summary) || fallback.summary; + const tone = toText(item.tone) || fallback.tone; + const playerGoal = toText(item.playerGoal) || fallback.playerGoal; + const generatedAttributeSchema = generateWorldAttributeSchema({ + worldName: name, + settingText: settingText.trim(), + summary, + tone, + playerGoal, + }); + const playableNpcs = normalizePlayableNpcList(item.playableNpcs); + const storyNpcs = normalizeStoryNpcList(item.storyNpcs); + const landmarkDrafts = toRecordArray(item.landmarks); + const camp = normalizeCampScene(item.camp, { + name, + summary, + tone, + playerGoal, + settingText: settingText.trim(), + }); + const runtimeBridge = resolveCustomWorldRuntimeIntentBridge(item); + + return { + id: + toText(item.id) || `custom-world-${Date.now().toString(36)}-${slugify(name)}`, + settingText: settingText.trim(), + name, + subtitle: toText(item.subtitle) || fallback.subtitle, + summary, + tone, + playerGoal, + cover: normalizeCustomWorldCover(item.cover, playableNpcs), + templateWorldType, + compatibilityTemplateWorldType: templateWorldType, + majorFactions: normalizeTags(item.majorFactions, []), + coreConflicts: normalizeTags(item.coreConflicts, [summary]), + attributeSchema: + item.attributeSchema && typeof item.attributeSchema === 'object' + ? generatedAttributeSchema + : generatedAttributeSchema, + playableNpcs, + storyNpcs, + items: normalizeItemList(item.items), + camp, + landmarks: normalizeLandmarks({ + landmarks: landmarkDrafts, + storyNpcs, + }), + themePack: + item.themePack && typeof item.themePack === 'object' + ? (item.themePack as CustomWorldProfile['themePack']) + : null, + storyGraph: + item.storyGraph && typeof item.storyGraph === 'object' + ? (item.storyGraph as CustomWorldProfile['storyGraph']) + : null, + anchorContent: + item.anchorContent && typeof item.anchorContent === 'object' + ? (item.anchorContent as Record) + : null, + creatorIntent: runtimeBridge.creatorIntent, + anchorPack: runtimeBridge.anchorPack, + lockState: runtimeBridge.lockState, + generationMode: + item.generationMode === 'fast' || item.generationMode === 'full' + ? item.generationMode + : fallback.generationMode, + generationStatus: + item.generationStatus === 'key_only' || item.generationStatus === 'complete' + ? item.generationStatus + : fallback.generationStatus, + ownedSettingLayers: + item.ownedSettingLayers && typeof item.ownedSettingLayers === 'object' + ? (item.ownedSettingLayers as Record) + : null, + knowledgeFacts: + Array.isArray(item.knowledgeFacts) + ? (item.knowledgeFacts as Array>) + : null, + threadContracts: + Array.isArray(item.threadContracts) + ? (item.threadContracts as Array>) + : null, + sceneChapterBlueprints: normalizeSceneChapterBlueprints( + item.sceneChapterBlueprints, + ), + scenarioPackId: toText(item.scenarioPackId) || null, + campaignPackId: toText(item.campaignPackId) || null, + }; +} + +export function buildCompiledCustomWorldProfile( + raw: unknown, + settingText: string, +): CustomWorldProfile { + const profile = normalizeCustomWorldProfile(raw, settingText); + const playableNpcs = profile.playableNpcs.map((npc, index) => { + const templateCharacterId = + npc.templateCharacterId ?? + pickCyclic( + PLAYABLE_TEMPLATE_CHARACTER_IDS, + index, + 'playable template character id', + ); + + return { + ...npc, + templateCharacterId, + attributeProfile: + npc.attributeProfile ?? + buildCustomWorldPlayableNpcAttributeProfile( + { + ...npc, + templateCharacterId, + }, + profile.attributeSchema, + ), + }; + }); + + const storyNpcs = profile.storyNpcs.map((npc) => ({ + ...npc, + attributeProfile: + npc.attributeProfile ?? + buildCustomWorldStoryNpcAttributeProfile(npc, profile.attributeSchema), + })); + + return { + ...profile, + playableNpcs, + storyNpcs, + scenarioPackId: + profile.scenarioPackId ?? `scenario-pack:${slugify(profile.name)}`, + campaignPackId: + profile.campaignPackId ?? `campaign-pack:${slugify(profile.name)}`, + }; +} + +function countUniqueNames(items: Array<{ name: string }>) { + return new Set(items.map((item) => item.name.trim()).filter(Boolean)).size; +} + +export function validateGeneratedCustomWorldProfile( + profile: CustomWorldProfile, +) { + const playableCount = countUniqueNames(profile.playableNpcs); + const landmarkCount = countUniqueNames(profile.landmarks); + + if (playableCount < MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT) { + throw new Error( + `自定义世界生成要求模型至少产出 ${MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT} 名可扮演角色。`, + ); + } + + if (landmarkCount < MIN_CUSTOM_WORLD_LANDMARK_COUNT) { + throw new Error( + `自定义世界生成要求至少产出 ${MIN_CUSTOM_WORLD_LANDMARK_COUNT} 个场景,当前仅返回 ${landmarkCount} 个。`, + ); + } + + const validStoryNpcIds = new Set(profile.storyNpcs.map((npc) => npc.id)); + const validLandmarkIds = new Set( + profile.landmarks.map((landmark) => landmark.id), + ); + + profile.landmarks.forEach((landmark) => { + const uniqueSceneNpcIds = [...new Set(landmark.sceneNpcIds)]; + if (uniqueSceneNpcIds.length < 3) { + throw new Error( + `场景「${landmark.name}」至少需要关联 3 个场景角色,当前仅有 ${uniqueSceneNpcIds.length} 个。`, + ); + } + if (uniqueSceneNpcIds.some((npcId) => !validStoryNpcIds.has(npcId))) { + throw new Error(`场景「${landmark.name}」引用了不存在的场景角色。`); + } + if (landmark.connections.length === 0) { + throw new Error(`场景「${landmark.name}」缺少可用的场景连接关系。`); + } + if ( + landmark.connections.some( + (connection) => + connection.targetLandmarkId === landmark.id || + !validLandmarkIds.has(connection.targetLandmarkId), + ) + ) { + throw new Error(`场景「${landmark.name}」存在无效的目标场景连接。`); + } + }); +} diff --git a/server-node/src/modules/custom-world/runtime-profile/creatorIntentBridge.ts b/server-node/src/modules/custom-world/runtime-profile/creatorIntentBridge.ts new file mode 100644 index 00000000..e194eae5 --- /dev/null +++ b/server-node/src/modules/custom-world/runtime-profile/creatorIntentBridge.ts @@ -0,0 +1,82 @@ +import { + buildCustomWorldAnchorPackFromIntent, + deriveCustomWorldLockStateFromIntent, + normalizeCustomWorldCreatorIntent, + normalizeCustomWorldLockState, +} from '../creatorIntentRuntime.js'; +import type { + CustomWorldCreatorIntent, + CustomWorldProfile, + WorldType, +} from '../runtimeTypes.js'; +import { toText } from './normalizeShared.js'; + +/** + * 工作包 G: + * 统一 runtime profile 对 creator intent、anchor pack 和 lock state 的桥接入口, + * 避免主编译器继续直接拼装这些兼容字段。 + */ + +export function inferWorldTypeFromSetting(settingText: string): WorldType { + return /[仙灵修真飞升灵脉宗门法器天宫秘境道骨星舰]/u.test(settingText) + ? 'XIANXIA' + : 'WUXIA'; +} + +export function normalizeWorldType(value: unknown, sourceText: string): WorldType { + const worldType = toText(value).toUpperCase(); + if (worldType === 'WUXIA' || worldType === 'XIANXIA') { + return worldType; + } + return inferWorldTypeFromSetting(sourceText); +} + +export function buildSeedPhrase(settingText: string, fallback: string) { + const compact = settingText.replace(/\s+/g, '').trim(); + return compact ? compact.slice(0, 10) : fallback; +} + +export function buildWorldName(settingText: string, worldType: WorldType) { + const seed = buildSeedPhrase(settingText, '新旅'); + const suffix = worldType === 'XIANXIA' ? '境' : '域'; + return `${seed}${suffix}`; +} + +export { + normalizeCustomWorldCreatorIntent, + normalizeCustomWorldLockState, +}; + +export function buildEmptyCustomWorldRuntimeBridge() { + return { + creatorIntent: null, + anchorPack: null, + lockState: normalizeCustomWorldLockState(null), + } satisfies { + creatorIntent: CustomWorldCreatorIntent | null; + anchorPack: CustomWorldProfile['anchorPack']; + lockState: CustomWorldProfile['lockState']; + }; +} + +export function resolveCustomWorldRuntimeIntentBridge( + raw: Record, +) { + const creatorIntent = normalizeCustomWorldCreatorIntent(raw.creatorIntent); + + return { + creatorIntent, + anchorPack: + raw.anchorPack && typeof raw.anchorPack === 'object' + ? (raw.anchorPack as CustomWorldProfile['anchorPack']) + : buildCustomWorldAnchorPackFromIntent(creatorIntent), + lockState: + raw.lockState && typeof raw.lockState === 'object' + ? normalizeCustomWorldLockState(raw.lockState) + : deriveCustomWorldLockStateFromIntent(creatorIntent), + } satisfies { + creatorIntent: CustomWorldCreatorIntent | null; + anchorPack: CustomWorldProfile['anchorPack']; + lockState: CustomWorldProfile['lockState']; + }; +} diff --git a/server-node/src/modules/custom-world/runtime-profile/index.ts b/server-node/src/modules/custom-world/runtime-profile/index.ts new file mode 100644 index 00000000..60911cc7 --- /dev/null +++ b/server-node/src/modules/custom-world/runtime-profile/index.ts @@ -0,0 +1,13 @@ +/** + * 工作包 G: + * custom world runtime profile 的主入口统一收口到目录化模块。 + * 主编译逻辑和各类 normalize/build 子模块已经物理拆分,旧 compiler 文件只保留兼容转发。 + */ +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'; diff --git a/server-node/src/modules/custom-world/runtime-profile/normalizeCamp.ts b/server-node/src/modules/custom-world/runtime-profile/normalizeCamp.ts new file mode 100644 index 00000000..efdf5d30 --- /dev/null +++ b/server-node/src/modules/custom-world/runtime-profile/normalizeCamp.ts @@ -0,0 +1,178 @@ +import type { + CustomWorldCampScene, + CustomWorldGenerationCampOutline, +} from '../runtimeTypes.js'; +import { + clampText, + toRecordArray, + toStringArray, + toText, +} from './normalizeShared.js'; + +/** + * 工作包 G: + * 营地 fallback、outline 归一和 runtime 场景归一单独收口, + * 避免主编译器继续混合 UI 展示语义和营地领域默认值。 + */ + +export type CustomWorldCampFallbackProfile = { + name: string; + summary: string; + tone: string; + playerGoal: string; + settingText: string; +}; + +function detectCustomWorldThemeMode(profile: { + settingText: string; + summary: string; + tone: string; + playerGoal: string; +}) { + const source = [ + profile.settingText, + profile.summary, + profile.tone, + profile.playerGoal, + ].join(' '); + + if (/[机关蒸汽齿轮工坊机巧城轨炮舰]/u.test(source)) return 'machina'; + if (/[海潮港湾船舟湖泊澜雾湾]/u.test(source)) return 'tide'; + if (/[裂缝裂界边境前线断层界桥灰域]/u.test(source)) return 'rift'; + if (/[修真仙灵宗门法器道脉秘境云阙]/u.test(source)) return 'arcane'; + if (/[江湖门派镖局朝廷刀剑侠客旧案]/u.test(source)) return 'martial'; + return 'mythic'; +} + +function sanitizeCampSeed(name: string) { + const normalized = name.trim().replace(/\s+/g, ''); + if (!normalized) { + return ''; + } + + const stripped = normalized.replace( + /(世界|江湖|边城|仙洲|仙境|灵境|界|录|域|境)$/u, + '', + ); + const seed = stripped || normalized; + + return seed.slice(0, Math.min(seed.length, 4)); +} + +function buildFallbackCampName(profile: CustomWorldCampFallbackProfile) { + const seed = sanitizeCampSeed(profile.name) || '归途'; + const themeMode = detectCustomWorldThemeMode(profile); + + const suffixByMode = { + mythic: '归舍', + martial: '归舍', + arcane: '栖居', + machina: '整备居', + tide: '潮居', + rift: '界隙居所', + } as const; + + return `${seed}${suffixByMode[themeMode]}`; +} + +export function buildFallbackCustomWorldCampScene( + profile: CustomWorldCampFallbackProfile, +): CustomWorldCampScene { + const fallbackName = buildFallbackCampName(profile); + const summaryLead = clampText(profile.summary, 24) || '前路尚未明朗'; + const goalLead = clampText(profile.playerGoal, 24) || '继续整理线索'; + const themeMode = detectCustomWorldThemeMode(profile); + + const descriptionByMode = { + mythic: `${fallbackName}是你在${profile.name}暂时安顿的归处,能整备行装、交换判断,并围绕“${goalLead}”继续启程。`, + martial: `${fallbackName}是你在${profile.name}暂时落脚的归处,能整备行装、收拢同伴,并朝“${goalLead}”继续动身。`, + arcane: `${fallbackName}是你在${profile.name}暂时栖身的居所,适合调息、整理法器,并围绕“${summaryLead}”交换判断。`, + machina: `${fallbackName}是你在${profile.name}的临时居所,能检修装备、补齐物资,并为下一段行动重新校准节奏。`, + tide: `${fallbackName}是你在${profile.name}靠潮歇息的住处,能安顿队伍、整理补给,并顺着“${goalLead}”继续前探。`, + rift: `${fallbackName}是你在${profile.name}勉强守住的一处居所,既能短暂停步,也能围绕“${summaryLead}”商量下一步去向。`, + } as const; + + return { + id: 'custom-scene-camp', + name: fallbackName, + description: descriptionByMode[themeMode], + dangerLevel: 'low', + sceneNpcIds: [], + connections: [], + narrativeResidues: null, + }; +} + +export function normalizeCampOutline( + value: unknown, + fallbackProfile: CustomWorldCampFallbackProfile, +) { + const fallback = buildFallbackCustomWorldCampScene(fallbackProfile); + const item = + value && typeof value === 'object' + ? (value as Record) + : {}; + + return { + id: toText(item.id) || fallback.id, + name: toText(item.name) || fallback.name, + description: toText(item.description) || fallback.description, + visualDescription: toText(item.visualDescription) || undefined, + dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel, + imageSrc: toText(item.imageSrc) || undefined, + sceneNpcIds: toStringArray(item.sceneNpcIds), + connections: toRecordArray(item.connections) + .map((connection) => ({ + targetLandmarkName: + toText(connection.targetLandmarkName) || + toText(connection.target) || + toText(connection.sceneName), + relativePosition: + toText(connection.relativePosition) || + toText(connection.position) || + 'forward', + summary: toText(connection.summary) || toText(connection.description), + })) + .filter((connection) => connection.targetLandmarkName), + } satisfies CustomWorldGenerationCampOutline & { + id: string; + visualDescription?: string; + imageSrc?: string; + sceneNpcIds: string[]; + connections: Array<{ + targetLandmarkName: string; + relativePosition: string; + summary: string; + }>; + }; +} + +export function normalizeCampScene( + value: unknown, + fallbackProfile: CustomWorldCampFallbackProfile, +): CustomWorldCampScene { + const fallback = buildFallbackCustomWorldCampScene(fallbackProfile); + const item = + value && typeof value === 'object' + ? (value as Record) + : {}; + + return { + id: toText(item.id) || fallback.id, + name: toText(item.name) || fallback.name, + description: toText(item.description) || fallback.description, + visualDescription: toText(item.visualDescription) || undefined, + dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel, + imageSrc: toText(item.imageSrc) || undefined, + sceneNpcIds: toStringArray(item.sceneNpcIds), + connections: toRecordArray(item.connections) + .map((connection) => ({ + targetLandmarkId: toText(connection.targetLandmarkId), + relativePosition: + toText(connection.relativePosition) || toText(connection.position) || 'forward', + summary: toText(connection.summary) || toText(connection.description), + })) + .filter((connection) => connection.targetLandmarkId), + narrativeResidues: null, + }; +} diff --git a/server-node/src/modules/custom-world/runtime-profile/normalizeLandmark.ts b/server-node/src/modules/custom-world/runtime-profile/normalizeLandmark.ts new file mode 100644 index 00000000..ee7f5041 --- /dev/null +++ b/server-node/src/modules/custom-world/runtime-profile/normalizeLandmark.ts @@ -0,0 +1,151 @@ +import type { + CustomWorldGenerationFramework, + CustomWorldGenerationLandmarkOutline, + CustomWorldNpc, +} from '../runtimeTypes.js'; +import { + clampText, + createEntryId, + MIN_CUSTOM_WORLD_LANDMARK_COUNT, + toRecordArray, + toStringArray, + toText, +} from './normalizeShared.js'; + +/** + * 工作包 G: + * 世界地点 outline/runtime 归一独立收口,避免地点网络解析继续和角色、场景章节逻辑混在一个文件里。 + */ + +export function normalizeLandmarkOutlineList(value: unknown) { + return toRecordArray(value) + .map((item) => { + const name = toText(item.name); + return { + name, + description: + toText(item.description) || + clampText(`${name}暗藏新的局势变化。`, 40), + visualDescription: toText(item.visualDescription) || undefined, + dangerLevel: toText(item.dangerLevel) || 'medium', + sceneNpcNames: [ + ...toStringArray(item.sceneNpcNames), + ...toStringArray(item.npcs, 'name'), + ...toStringArray(item.sceneNpcs, 'name'), + ...toStringArray(item.npcNames), + ], + connections: toRecordArray(item.connections) + .map((connection) => ({ + targetLandmarkName: + toText(connection.targetLandmarkName) || + toText(connection.target) || + toText(connection.sceneName), + relativePosition: + toText(connection.relativePosition) || + toText(connection.position) || + 'forward', + summary: + toText(connection.summary) || toText(connection.description), + })) + .filter((connection) => connection.targetLandmarkName), + } satisfies CustomWorldGenerationLandmarkOutline; + }) + .filter((entry) => entry.name) + .slice(0, MIN_CUSTOM_WORLD_LANDMARK_COUNT); +} + +export function normalizeCustomWorldGenerationLandmarkOutlineBatch(raw: unknown) { + const item = + raw && typeof raw === 'object' ? (raw as Record) : {}; + return normalizeLandmarkOutlineList(item.landmarks); +} + +export function buildCustomWorldRawProfileLandmarksFromFramework( + framework: CustomWorldGenerationFramework, +) { + return framework.landmarks.map((landmark) => ({ + name: landmark.name, + description: landmark.description, + visualDescription: landmark.visualDescription, + dangerLevel: landmark.dangerLevel, + sceneNpcNames: [...landmark.sceneNpcNames], + connections: landmark.connections.map((connection) => ({ + targetLandmarkName: connection.targetLandmarkName, + relativePosition: connection.relativePosition, + summary: connection.summary, + })), + })); +} + +export function normalizeLandmarks(params: { + landmarks: Array>; + storyNpcs: CustomWorldNpc[]; +}) { + const storyNpcIdByName = new Map( + params.storyNpcs.map((npc) => [npc.name.trim(), npc.id] as const), + ); + const landmarkEntries = params.landmarks + .map((item, index) => ({ + id: toText(item.id) || createEntryId('landmark', toText(item.name), index), + name: toText(item.name), + description: toText(item.description), + visualDescription: toText(item.visualDescription) || undefined, + dangerLevel: toText(item.dangerLevel) || 'medium', + imageSrc: toText(item.imageSrc) || undefined, + sceneNpcIds: toStringArray(item.sceneNpcIds), + sceneNpcNames: [ + ...toStringArray(item.sceneNpcNames), + ...toStringArray(item.npcs, 'name'), + ...toStringArray(item.sceneNpcs, 'name'), + ...toStringArray(item.npcNames), + ], + connections: toRecordArray(item.connections).map((connection) => ({ + targetLandmarkId: toText(connection.targetLandmarkId), + targetLandmarkName: + toText(connection.targetLandmarkName) || + toText(connection.target) || + toText(connection.sceneName), + relativePosition: + toText(connection.relativePosition) || toText(connection.position), + summary: toText(connection.summary) || toText(connection.description), + })), + })) + .filter((entry) => entry.name); + + const landmarkIdByName = new Map( + landmarkEntries.map((landmark) => [landmark.name.trim(), landmark.id] as const), + ); + + return landmarkEntries.map((landmark) => { + const resolvedSceneNpcIds = [ + ...new Set( + [ + ...landmark.sceneNpcIds, + ...landmark.sceneNpcNames + .map((name) => storyNpcIdByName.get(name.trim()) ?? '') + .filter(Boolean), + ].filter(Boolean), + ), + ]; + + return { + id: landmark.id, + name: landmark.name, + description: landmark.description, + visualDescription: landmark.visualDescription, + dangerLevel: landmark.dangerLevel, + imageSrc: landmark.imageSrc, + sceneNpcIds: resolvedSceneNpcIds, + connections: landmark.connections + .map((connection) => ({ + targetLandmarkId: + connection.targetLandmarkId || + landmarkIdByName.get(connection.targetLandmarkName.trim()) || + '', + relativePosition: connection.relativePosition || 'forward', + summary: connection.summary, + })) + .filter((connection) => connection.targetLandmarkId), + }; + }); +} diff --git a/server-node/src/modules/custom-world/runtime-profile/normalizeRole.ts b/server-node/src/modules/custom-world/runtime-profile/normalizeRole.ts new file mode 100644 index 00000000..ff8e5e92 --- /dev/null +++ b/server-node/src/modules/custom-world/runtime-profile/normalizeRole.ts @@ -0,0 +1,541 @@ +import type { + CharacterBackstoryChapter, + CharacterBackstoryRevealConfig, + CustomWorldGenerationFramework, + CustomWorldGenerationRoleBatchType, + CustomWorldGenerationRoleOutline, + CustomWorldNpc, + CustomWorldPlayableNpc, + CustomWorldProfile, + CustomWorldRoleInitialItem, + CustomWorldRoleProfile, + CustomWorldRoleSkill, +} from '../runtimeTypes.js'; +import { + buildWorldName, + normalizeWorldType, +} from './creatorIntentBridge.js'; +import { + clampCustomWorldAffinity, + clampText, + createEntryId, + MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, + MIN_CUSTOM_WORLD_STORY_NPC_COUNT, + normalizeInitialAffinity, + normalizeRarity, + normalizeRoleItemCategory, + normalizeTags, + toRecordArray, + toText, +} from './normalizeShared.js'; + +/** + * 工作包 G: + * 把角色相关的 outline/runtime 归一、背景揭示、初始技能与物品 fallback 统一收口, + * 让主编译器只负责装配,不继续内嵌角色画像细节。 + */ + +const AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS = [15, 30, 60, 90] as const; +const DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY = 60; +const DEFAULT_PLAYABLE_INITIAL_AFFINITY = 18; +const DEFAULT_STORY_NPC_INITIAL_AFFINITY = 6; +const DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT = 3; +const DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT = 3; +const CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = [ + '表层来意', + '旧事裂痕', + '隐藏执念', + '最终底牌', +] as const; + +type CustomWorldRoleFallbackSource = Pick< + CustomWorldRoleProfile, + | 'name' + | 'title' + | 'role' + | 'description' + | 'backstory' + | 'personality' + | 'motivation' + | 'combatStyle' + | 'relationshipHooks' + | 'tags' +>; + +function splitNarrativeSentences(text: string) { + const normalized = text.replace(/\s+/g, ' ').trim(); + if (!normalized) { + return []; + } + const matches = normalized.match(/[^。!?!?]+[。!?!?]?/gu); + return (matches ?? [normalized]).map((item) => item.trim()).filter(Boolean); +} + +function buildFallbackBackstoryReveal( + source: CustomWorldRoleFallbackSource, +): CharacterBackstoryRevealConfig { + const normalizedBackstory = + source.backstory.trim() || `${source.name}对自己的过去始终有所保留。`; + const backstorySentences = splitNarrativeSentences(normalizedBackstory); + const backstoryLead = backstorySentences[0] ?? normalizedBackstory; + const backstoryDetail = + backstorySentences.slice(0, 2).join('') || normalizedBackstory; + const publicSummary = + source.description.trim() || clampText(normalizedBackstory, 42); + const fallbackContents = [ + source.description.trim() || backstoryLead, + backstoryDetail, + source.motivation.trim() + ? `${source.name}真正挂念的,是:${source.motivation.trim()}` + : `${source.name}的选择与“${clampText(backstoryLead, 24)}”直接相关。`, + source.personality.trim() + ? `${source.name}不会轻易说出的底色,是:${source.personality.trim()}` + : `${source.name}仍把最深的筹码藏在过去之中。`, + ]; + + return { + publicSummary, + privateChatUnlockAffinity: DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY, + chapters: AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.map( + (affinityRequired, index) => + ({ + id: createEntryId( + 'backstory-chapter', + `${source.name}-${CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? index + 1}`, + index, + ), + title: + CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? + `背景片段${index + 1}`, + affinityRequired, + teaser: clampText( + fallbackContents[index] ?? normalizedBackstory, + 22, + ), + content: clampText( + fallbackContents[index] ?? normalizedBackstory, + 72, + ), + contextSnippet: clampText( + `${source.name}的背景正在向“${fallbackContents[index] ?? normalizedBackstory}”这条线索展开。`, + 48, + ), + }) satisfies CharacterBackstoryChapter, + ), + }; +} + +function normalizeBackstoryReveal( + value: unknown, + fallbackSource: CustomWorldRoleFallbackSource, +) { + const fallback = buildFallbackBackstoryReveal(fallbackSource); + if (!value || typeof value !== 'object') { + return fallback; + } + + const item = value as Record; + const rawChapters = toRecordArray(item.chapters); + + return { + publicSummary: toText(item.publicSummary) || fallback.publicSummary, + privateChatUnlockAffinity: + typeof item.privateChatUnlockAffinity === 'number' && + Number.isFinite(item.privateChatUnlockAffinity) + ? clampCustomWorldAffinity(item.privateChatUnlockAffinity) + : fallback.privateChatUnlockAffinity, + chapters: AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.map( + (defaultAffinity, index) => { + const fallbackChapter = fallback.chapters[index]; + const rawChapter = rawChapters[index]; + return { + id: + (rawChapter && toText(rawChapter.id)) || + fallbackChapter?.id || + `backstory-chapter-${index + 1}`, + title: + (rawChapter && toText(rawChapter.title)) || + fallbackChapter?.title || + `背景片段${index + 1}`, + affinityRequired: + fallbackChapter?.affinityRequired ?? defaultAffinity, + teaser: + (rawChapter && toText(rawChapter.teaser)) || + fallbackChapter?.teaser || + '', + content: + (rawChapter && toText(rawChapter.content)) || + fallbackChapter?.content || + '', + contextSnippet: + (rawChapter && toText(rawChapter.contextSnippet)) || + fallbackChapter?.contextSnippet || + '', + } satisfies CharacterBackstoryChapter; + }, + ), + } satisfies CharacterBackstoryRevealConfig; +} + +function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) { + const skillNameSeed = source.title || source.role || source.name || '角色'; + const skillSummarySeed = + source.combatStyle || source.description || `${source.name}善于把握局势。`; + const motivationSeed = + source.motivation || source.personality || source.backstory; + + return [ + { + id: createEntryId('role-skill', `${skillNameSeed}-起手`, 0), + name: `${skillNameSeed}起手`, + summary: clampText(skillSummarySeed, 36), + style: '起手压制', + }, + { + id: createEntryId('role-skill', `${skillNameSeed}-机动`, 1), + name: `${skillNameSeed}变招`, + summary: clampText( + source.personality || `${source.name}习惯在试探中寻找破绽。`, + 36, + ), + style: '机动周旋', + }, + { + id: createEntryId('role-skill', `${skillNameSeed}-底牌`, 2), + name: `${skillNameSeed}底牌`, + summary: clampText( + motivationSeed || `${source.name}会在关键时刻亮出压箱手段。`, + 36, + ), + style: '爆发终结', + }, + ] satisfies CustomWorldRoleSkill[]; +} + +function normalizeRoleSkillList( + value: unknown, + fallbackSource: CustomWorldRoleFallbackSource, +) { + const normalized = toRecordArray(value) + .map((item, index) => { + const name = toText(item.name); + const summary = toText(item.summary) || toText(item.description); + const style = toText(item.style) || toText(item.category) || '常用'; + + return { + id: createEntryId('role-skill', name || style, index), + name, + summary, + style, + } satisfies CustomWorldRoleSkill; + }) + .filter((entry) => entry.name) + .slice(0, DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT); + + return normalized.length > 0 + ? normalized + : buildFallbackRoleSkills(fallbackSource); +} + +function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) { + const itemNameSeed = source.title || source.role || source.name || '角色'; + return [ + { + id: createEntryId('role-item', `${itemNameSeed}-1`, 0), + name: `${itemNameSeed}常备武具`, + category: '武器', + quantity: 1, + rarity: 'rare', + description: clampText( + source.combatStyle || `${source.name}随身携带的主要作战物件。`, + 36, + ), + tags: normalizeTags(source.tags, ['战斗', '随身']), + }, + { + id: createEntryId('role-item', `${itemNameSeed}-2`, 1), + name: `${itemNameSeed}补给包`, + category: '消耗品', + quantity: 2, + rarity: 'uncommon', + description: clampText( + source.personality || `${source.name}为了长期行动准备的基础补给。`, + 36, + ), + tags: normalizeTags(source.relationshipHooks, ['补给', '行动']), + }, + { + id: createEntryId('role-item', `${itemNameSeed}-3`, 2), + name: `${itemNameSeed}私人物件`, + category: '专属物品', + quantity: 1, + rarity: 'rare', + description: clampText( + source.backstory || + source.motivation || + `${source.name}不愿随意交出的信物。`, + 36, + ), + tags: normalizeTags( + [...source.tags, ...source.relationshipHooks], + ['信物', '线索'], + ), + }, + ] satisfies CustomWorldRoleInitialItem[]; +} + +function normalizeRoleInitialItemList( + value: unknown, + fallbackSource: CustomWorldRoleFallbackSource, +) { + const normalized = toRecordArray(value) + .map((item, index) => { + const name = toText(item.name); + return { + id: createEntryId('role-item', name, index), + name, + category: normalizeRoleItemCategory(item.category), + quantity: + typeof item.quantity === 'number' && Number.isFinite(item.quantity) + ? Math.max(1, Math.min(99, Math.round(item.quantity))) + : 1, + rarity: normalizeRarity(item.rarity, 'rare'), + description: toText(item.description), + tags: normalizeTags(item.tags), + } satisfies CustomWorldRoleInitialItem; + }) + .filter((entry) => entry.name) + .slice(0, DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT); + + return normalized.length > 0 + ? normalized + : buildFallbackRoleInitialItems(fallbackSource); +} + +function normalizeRoleOutlineList( + value: unknown, + options: { + titleFallback: string; + defaultAffinity: number; + maxCount?: number; + }, +) { + const normalized = toRecordArray(value) + .map((item) => { + const name = toText(item.name); + const title = + toText(item.title) || toText(item.role) || options.titleFallback; + const role = toText(item.role) || title; + const relationshipHooks = normalizeTags( + item.relationshipHooks, + normalizeTags(item.tags), + ); + + return { + name, + title, + role, + description: + toText(item.description) || + clampText(`${name || title}在世界中以${role}身份活动。`, 36), + visualDescription: toText(item.visualDescription) || undefined, + actionDescription: toText(item.actionDescription) || undefined, + sceneVisualDescription: toText(item.sceneVisualDescription) || undefined, + initialAffinity: normalizeInitialAffinity( + item.initialAffinity, + options.defaultAffinity, + ), + relationshipHooks, + tags: normalizeTags(item.tags, relationshipHooks), + } satisfies CustomWorldGenerationRoleOutline; + }) + .filter((entry) => entry.name); + + return typeof options.maxCount === 'number' + ? normalized.slice(0, options.maxCount) + : normalized; +} + +export function normalizeCustomWorldGenerationRoleOutlineBatch( + raw: unknown, + roleType: CustomWorldGenerationRoleBatchType, +) { + const item = + raw && typeof raw === 'object' ? (raw as Record) : {}; + const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'; + + return normalizeRoleOutlineList(item[key], { + titleFallback: '未定称号', + defaultAffinity: + roleType === 'playable' + ? DEFAULT_PLAYABLE_INITIAL_AFFINITY + : DEFAULT_STORY_NPC_INITIAL_AFFINITY, + }); +} + +export function normalizeCustomWorldGenerationFrameworkRoles(params: { + raw: Record; + fallback: CustomWorldProfile; + settingText: string; +}) { + const worldSignalText = [ + params.settingText, + toText(params.raw.subtitle), + toText(params.raw.summary), + toText(params.raw.tone), + toText(params.raw.playerGoal), + ].join(' '); + const templateWorldType = normalizeWorldType( + params.raw.templateWorldType, + worldSignalText, + ); + const name = + toText(params.raw.name) || buildWorldName(params.settingText, templateWorldType); + + return { + name, + templateWorldType, + playableNpcs: normalizeRoleOutlineList(params.raw.playableNpcs, { + titleFallback: '未定称号', + defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY, + maxCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, + }), + storyNpcs: normalizeRoleOutlineList(params.raw.storyNpcs, { + titleFallback: '未定称号', + defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY, + maxCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT, + }), + campFallbackProfile: { + name, + summary: toText(params.raw.summary) || params.fallback.summary, + tone: toText(params.raw.tone) || params.fallback.tone, + playerGoal: toText(params.raw.playerGoal) || params.fallback.playerGoal, + settingText: params.settingText.trim(), + }, + }; +} + +export function buildCustomWorldRawProfileRolesFromFramework( + framework: CustomWorldGenerationFramework, +) { + return { + playableNpcs: framework.playableNpcs.map((npc) => ({ + name: npc.name, + title: npc.title, + role: npc.role, + description: npc.description, + visualDescription: npc.visualDescription, + actionDescription: npc.actionDescription, + sceneVisualDescription: npc.sceneVisualDescription, + initialAffinity: npc.initialAffinity, + relationshipHooks: [...npc.relationshipHooks], + tags: [...npc.tags], + })), + storyNpcs: framework.storyNpcs.map((npc) => ({ + name: npc.name, + title: npc.title, + role: npc.role, + description: npc.description, + visualDescription: npc.visualDescription, + actionDescription: npc.actionDescription, + sceneVisualDescription: npc.sceneVisualDescription, + initialAffinity: npc.initialAffinity, + relationshipHooks: [...npc.relationshipHooks], + tags: [...npc.tags], + })), + }; +} + +function normalizeRoleProfile( + item: Record, + index: number, + options: { + idPrefix: 'playable-npc' | 'story-npc'; + titleFallback: string; + defaultAffinity: number; + }, +) { + const name = toText(item.name); + const title = + toText(item.title) || toText(item.role) || options.titleFallback; + const role = toText(item.role) || title; + const relationshipHooks = normalizeTags( + item.relationshipHooks, + normalizeTags(item.tags), + ); + const normalizedRole = { + id: toText(item.id) || createEntryId(options.idPrefix, name, index), + name, + title, + role, + description: toText(item.description), + visualDescription: toText(item.visualDescription) || undefined, + actionDescription: toText(item.actionDescription) || undefined, + sceneVisualDescription: toText(item.sceneVisualDescription) || undefined, + backstory: toText(item.backstory), + personality: toText(item.personality), + motivation: toText(item.motivation) || toText(item.description), + combatStyle: toText(item.combatStyle), + initialAffinity: normalizeInitialAffinity( + item.initialAffinity, + options.defaultAffinity, + ), + relationshipHooks, + tags: normalizeTags(item.tags, relationshipHooks), + }; + + return { + ...normalizedRole, + backstoryReveal: normalizeBackstoryReveal( + item.backstoryReveal, + normalizedRole, + ), + skills: normalizeRoleSkillList(item.skills, normalizedRole), + initialItems: normalizeRoleInitialItemList(item.initialItems, normalizedRole), + imageSrc: toText(item.imageSrc) || undefined, + generatedVisualAssetId: toText(item.generatedVisualAssetId) || undefined, + generatedAnimationSetId: + toText(item.generatedAnimationSetId) || undefined, + animationMap: + item.animationMap && typeof item.animationMap === 'object' + ? (item.animationMap as Record) + : undefined, + narrativeProfile: + item.narrativeProfile && typeof item.narrativeProfile === 'object' + ? (item.narrativeProfile as CustomWorldRoleProfile['narrativeProfile']) + : null, + }; +} + +export function normalizePlayableNpcList(value: unknown) { + return toRecordArray(value) + .map((item, index) => ({ + ...normalizeRoleProfile(item, index, { + idPrefix: 'playable-npc', + titleFallback: '未定称号', + defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY, + }), + templateCharacterId: toText(item.templateCharacterId) || undefined, + })) + .filter((entry) => entry.name) + .slice(0, MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT); +} + +export function normalizeStoryNpcList(value: unknown) { + return toRecordArray(value) + .map( + (item, index) => + ({ + ...normalizeRoleProfile(item, index, { + idPrefix: 'story-npc', + titleFallback: '未定称号', + defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY, + }), + visual: + item.visual && typeof item.visual === 'object' + ? (item.visual as Record) + : undefined, + }) satisfies CustomWorldNpc, + ) + .filter((entry) => entry.name); +} diff --git a/server-node/src/modules/custom-world/runtime-profile/normalizeSceneChapter.ts b/server-node/src/modules/custom-world/runtime-profile/normalizeSceneChapter.ts new file mode 100644 index 00000000..08d1bad3 --- /dev/null +++ b/server-node/src/modules/custom-world/runtime-profile/normalizeSceneChapter.ts @@ -0,0 +1,123 @@ +import type { SceneActBlueprint, SceneChapterBlueprint } from '../runtimeTypes.js'; +import { createEntryId, toRecordArray, toStringArray, toText } from './normalizeShared.js'; + +/** + * 工作包 G: + * 分幕与场景章节 blueprint 归一独立收口,让结果预览编译层只消费稳定的章节结构。 + */ + +const SCENE_ACT_STAGES = new Set([ + 'opening', + 'expansion', + 'turning_point', + 'climax', + 'aftermath', +]); +const SCENE_ACT_ADVANCE_RULES = new Set([ + 'after_primary_contact', + 'after_active_step_complete', + 'after_chapter_resolution', +]); + +function normalizeSceneActStageCoverage(value: unknown) { + const stageCoverage = Array.isArray(value) + ? value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry): entry is SceneActBlueprint['stageCoverage'][number] => + SCENE_ACT_STAGES.has(entry as never), + ) + : []; + + return [...new Set(stageCoverage)]; +} + +function normalizeSceneActBlueprint( + value: unknown, + index: number, + sceneId: string, +): SceneActBlueprint | null { + const item = + value && typeof value === 'object' + ? (value as Record) + : null; + if (!item) { + return null; + } + + const encounterNpcIds = toStringArray(item.encounterNpcIds); + const stageCoverage = normalizeSceneActStageCoverage(item.stageCoverage); + const advanceRule = toText(item.advanceRule); + const title = toText(item.title); + const summary = toText(item.summary); + + if (!title && !summary && encounterNpcIds.length === 0) { + return null; + } + + return { + id: + toText(item.id) || + createEntryId(`saved-scene-act-${sceneId}`, title || sceneId, index), + sceneId, + title: title || `第 ${index + 1} 幕`, + summary: summary || title || `围绕${sceneId}继续推进`, + stageCoverage: + stageCoverage.length > 0 + ? stageCoverage + : index === 0 + ? ['opening'] + : ['climax', 'aftermath'], + backgroundImageSrc: toText(item.backgroundImageSrc) || undefined, + backgroundAssetId: toText(item.backgroundAssetId) || undefined, + encounterNpcIds, + primaryNpcId: toText(item.primaryNpcId) || encounterNpcIds[0] || '', + linkedThreadIds: toStringArray(item.linkedThreadIds), + advanceRule: SCENE_ACT_ADVANCE_RULES.has(advanceRule as never) + ? (advanceRule as SceneActBlueprint['advanceRule']) + : 'after_active_step_complete', + actGoal: toText(item.actGoal), + transitionHook: toText(item.transitionHook), + }; +} + +export function normalizeSceneChapterBlueprints(value: unknown) { + if (!Array.isArray(value)) { + return null; + } + + const normalized = value + .filter( + (entry): entry is Record => + Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry), + ) + .map((entry, index) => { + const sceneId = toText(entry.sceneId); + if (!sceneId) { + return null; + } + + const acts = Array.isArray(entry.acts) + ? entry.acts + .map((act, actIndex) => + normalizeSceneActBlueprint(act, actIndex, sceneId), + ) + .filter((act): act is SceneActBlueprint => Boolean(act)) + : []; + + return { + id: + toText(entry.id) || + createEntryId('saved-scene-chapter', sceneId, index), + sceneId, + title: toText(entry.title) || toText(entry.sceneName) || sceneId, + summary: toText(entry.summary), + linkedThreadIds: toStringArray(entry.linkedThreadIds), + linkedLandmarkIds: toStringArray(entry.linkedLandmarkIds), + acts, + } satisfies SceneChapterBlueprint; + }) + .filter((entry): entry is SceneChapterBlueprint => Boolean(entry)); + + return normalized.length > 0 ? normalized : null; +} diff --git a/server-node/src/modules/custom-world/runtime-profile/normalizeShared.ts b/server-node/src/modules/custom-world/runtime-profile/normalizeShared.ts new file mode 100644 index 00000000..9a18ef63 --- /dev/null +++ b/server-node/src/modules/custom-world/runtime-profile/normalizeShared.ts @@ -0,0 +1,248 @@ +import type { + CustomWorldCoverProfile, + CustomWorldCoverSourceType, + CustomWorldItem, + CustomWorldPlayableNpc, +} from '../runtimeTypes.js'; + +/** + * 工作包 G: + * 把 runtime profile 编译流程里的通用常量、基础文本归一和通用小型归一逻辑收口到共享模块, + * 让角色、地点、场景章节和主编译入口都不再重复维护这些基础能力。 + */ + +const MIN_CUSTOM_WORLD_AFFINITY = -40; +const MAX_CUSTOM_WORLD_AFFINITY = 90; +const CUSTOM_WORLD_RARITIES = [ + 'common', + 'uncommon', + 'rare', + 'epic', + 'legendary', +] as const; +const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = [ + '武器', + '护甲', + '饰品', + '消耗品', + '材料', + '稀有品', + '专属物品', + '专属物', +] as const; + +export const MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT = 5; +export const MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT = 30; +export const MIN_CUSTOM_WORLD_LANDMARK_COUNT = 10; +export const MIN_CUSTOM_WORLD_STORY_NPC_COUNT = Math.max( + 0, + MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT - MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, +); + +export const PLAYABLE_TEMPLATE_CHARACTER_IDS = [ + 'sword-princess', + 'archer-hero', + 'girl-hero', + 'punch-hero', + 'fighter-4', +] as const; + +export function toText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +export function toFiniteInteger(value: unknown) { + return typeof value === 'number' && Number.isFinite(value) + ? Math.round(value) + : undefined; +} + +export function toRecord(value: unknown) { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +export function toRecordArray(value: unknown) { + return Array.isArray(value) + ? (value.filter((item) => item && typeof item === 'object') as Array< + Record + >) + : []; +} + +export function toStringArray(value: unknown, nestedKey?: string) { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item) => { + if (typeof item === 'string') { + return item.trim(); + } + if (nestedKey && item && typeof item === 'object') { + return toText((item as Record)[nestedKey]); + } + return ''; + }) + .filter(Boolean); +} + +export function normalizeTags(value: unknown, fallbackTags: string[] = []) { + const tags = Array.isArray(value) + ? value.map((item) => toText(item)).filter(Boolean) + : []; + return [ + ...new Set((tags.length > 0 ? tags : fallbackTags).filter(Boolean)), + ].slice(0, 5); +} + +export function clampText(value: string, maxLength: number) { + const normalized = value.trim().replace(/\s+/g, ' '); + if (!normalized) { + return ''; + } + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`; +} + +export function slugify(value: string) { + const ascii = value + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-') + .replace(/^-+|-+$/g, ''); + + return ascii ? ascii.slice(0, 24) : 'entry'; +} + +export function createEntryId(prefix: string, label: string, index: number) { + return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`; +} + +export function clampCustomWorldAffinity(value: number) { + return Math.max( + MIN_CUSTOM_WORLD_AFFINITY, + Math.min(MAX_CUSTOM_WORLD_AFFINITY, Math.round(value)), + ); +} + +export function normalizeInitialAffinity(value: unknown, fallback: number) { + return typeof value === 'number' && Number.isFinite(value) + ? clampCustomWorldAffinity(value) + : fallback; +} + +export function normalizeRarity( + value: unknown, + fallback: (typeof CUSTOM_WORLD_RARITIES)[number] = 'rare', +) { + const rarity = toText(value).toLowerCase(); + return CUSTOM_WORLD_RARITIES.includes( + rarity as (typeof CUSTOM_WORLD_RARITIES)[number], + ) + ? (rarity as (typeof CUSTOM_WORLD_RARITIES)[number]) + : fallback; +} + +export function normalizeRoleItemCategory(value: unknown, fallback = '材料') { + const category = toText(value); + if ( + (CUSTOM_WORLD_ROLE_ITEM_CATEGORIES as readonly string[]).includes(category) + ) { + return category === '专属物' ? '专属物品' : category; + } + if (/武器|刀|剑|弓|枪|炮|锤/u.test(category)) return '武器'; + if (/甲|护|盾|衣|袍/u.test(category)) return '护甲'; + if (/饰|符|佩|坠|珠|印/u.test(category)) return '饰品'; + if (/药|食|补给|瓶|卷轴/u.test(category)) return '消耗品'; + if (/材|矿|木|石|鳞|骨/u.test(category)) return '材料'; + if (/秘|钥|图|册|录|卷/u.test(category)) return '稀有品'; + if (/信物|遗物|核心|母印|真符/u.test(category)) return '专属物品'; + return fallback; +} + +export function normalizeCustomWorldCoverCharacterRoleIds( + value: unknown, + playableNpcs: Array>, +) { + const availableIds = new Set( + playableNpcs.map((entry) => entry.id.trim()).filter(Boolean), + ); + const selectedIds = Array.isArray(value) + ? [ + ...new Set( + value + .map((entry) => toText(entry)) + .filter((entry) => entry && availableIds.has(entry)), + ), + ].slice(0, 3) + : []; + + if (selectedIds.length > 0) { + return selectedIds; + } + + return playableNpcs + .map((entry) => entry.id.trim()) + .filter(Boolean) + .slice(0, 3); +} + +export function buildDefaultCustomWorldCover( + playableNpcs: Array>, +): CustomWorldCoverProfile { + return { + sourceType: 'default' as const, + imageSrc: null, + characterRoleIds: normalizeCustomWorldCoverCharacterRoleIds( + undefined, + playableNpcs, + ), + }; +} + +export function normalizeCustomWorldCover( + value: unknown, + playableNpcs: Array>, +): CustomWorldCoverProfile { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return buildDefaultCustomWorldCover(playableNpcs); + } + + const item = value as Record; + const sourceType: CustomWorldCoverSourceType = + item.sourceType === 'uploaded' || item.sourceType === 'generated' + ? item.sourceType + : 'default'; + const imageSrc = toText(item.imageSrc) || null; + + if (sourceType !== 'default' && imageSrc) { + return { + sourceType, + imageSrc, + characterRoleIds: [], + }; + } + + return buildDefaultCustomWorldCover(playableNpcs); +} + +export function normalizeItemList(value: unknown) { + return toRecordArray(value) + .map((item, index) => { + const name = toText(item.name); + const category = toText(item.category); + return { + id: toText(item.id) || createEntryId('item', name, index), + name, + category, + rarity: normalizeRarity(item.rarity, 'rare'), + description: toText(item.description), + tags: normalizeTags(item.tags), + } satisfies CustomWorldItem; + }) + .filter((entry) => entry.name && entry.category); +} diff --git a/server-node/src/modules/custom-world/runtime-profile/runtimeProfileCompiler.ts b/server-node/src/modules/custom-world/runtime-profile/runtimeProfileCompiler.ts new file mode 100644 index 00000000..7a78cd49 --- /dev/null +++ b/server-node/src/modules/custom-world/runtime-profile/runtimeProfileCompiler.ts @@ -0,0 +1,13 @@ +/** + * 兼容期 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'; diff --git a/server-node/src/modules/custom-world/runtimeProfile.ts b/server-node/src/modules/custom-world/runtimeProfile.ts index 039b1f2a..f5debeb6 100644 --- a/server-node/src/modules/custom-world/runtimeProfile.ts +++ b/server-node/src/modules/custom-world/runtimeProfile.ts @@ -1,1884 +1,6 @@ -import { - buildCustomWorldAnchorPackFromIntent, - deriveCustomWorldLockStateFromIntent, - normalizeCustomWorldCreatorIntent, - normalizeCustomWorldLockState, -} from './creatorIntentRuntime.js'; -import type { - AttributeVector, - CharacterBackstoryChapter, - CharacterBackstoryRevealConfig, - CustomWorldCampScene, - CustomWorldCoverProfile, - CustomWorldCoverSourceType, - CustomWorldGenerationFramework, - CustomWorldGenerationLandmarkOutline, - CustomWorldGenerationRoleBatchType, - CustomWorldGenerationRoleOutline, - CustomWorldItem, - CustomWorldNpc, - CustomWorldPlayableNpc, - CustomWorldProfile, - CustomWorldRoleInitialItem, - CustomWorldRoleProfile, - CustomWorldRoleSkill, - RoleAttributeProfile, - SceneActBlueprint, - SceneChapterBlueprint, - WorldAttributeSchema, - WorldAttributeSlot, - WorldType, -} from './runtimeTypes.js'; - -export type { - CustomWorldGenerationFramework, - CustomWorldGenerationLandmarkOutline, - CustomWorldGenerationRoleBatchStage, - CustomWorldGenerationRoleBatchType, - CustomWorldGenerationRoleOutline, -} from './runtimeTypes.js'; - -const AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS = [15, 30, 60, 90] as const; -const DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY = 60; - -const MIN_CUSTOM_WORLD_AFFINITY = -40; -const MAX_CUSTOM_WORLD_AFFINITY = 90; -const DEFAULT_PLAYABLE_INITIAL_AFFINITY = 18; -const DEFAULT_STORY_NPC_INITIAL_AFFINITY = 6; -const DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT = 3; -const DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT = 3; -const CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = [ - '表层来意', - '旧事裂痕', - '隐藏执念', - '最终底牌', -] as const; -const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = [ - '武器', - '护甲', - '饰品', - '消耗品', - '材料', - '稀有品', - '专属物品', - '专属物', -] as const; -const CUSTOM_WORLD_RARITIES = [ - 'common', - 'uncommon', - 'rare', - 'epic', - 'legendary', -] as const; -const PLAYABLE_TEMPLATE_CHARACTER_IDS = [ - 'sword-princess', - 'archer-hero', - 'girl-hero', - 'punch-hero', - 'fighter-4', -] as const; -const WORLD_ATTRIBUTE_SLOT_IDS = [ - 'axis_a', - 'axis_b', - 'axis_c', - 'axis_d', - 'axis_e', - 'axis_f', -] as const; -const SCENE_ACT_STAGES = new Set([ - 'opening', - 'expansion', - 'turning_point', - 'climax', - 'aftermath', -]); -const SCENE_ACT_ADVANCE_RULES = new Set([ - 'after_primary_contact', - 'after_active_step_complete', - 'after_chapter_resolution', -]); - -export const MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT = 5; -export const MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT = 30; -export const MIN_CUSTOM_WORLD_LANDMARK_COUNT = 10; -export const MIN_CUSTOM_WORLD_STORY_NPC_COUNT = Math.max( - 0, - MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT - MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, -); - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function toFiniteInteger(value: unknown) { - return typeof value === 'number' && Number.isFinite(value) - ? Math.round(value) - : undefined; -} - -function toRecordArray(value: unknown) { - return Array.isArray(value) - ? (value.filter((item) => item && typeof item === 'object') as Array< - Record - >) - : []; -} - -function toStringArray(value: unknown, nestedKey?: string) { - if (!Array.isArray(value)) { - return []; - } - - return value - .map((item) => { - if (typeof item === 'string') { - return item.trim(); - } - if (nestedKey && item && typeof item === 'object') { - return toText((item as Record)[nestedKey]); - } - return ''; - }) - .filter(Boolean); -} - -function normalizeTags(value: unknown, fallbackTags: string[] = []) { - const tags = Array.isArray(value) - ? value.map((item) => toText(item)).filter(Boolean) - : []; - return [ - ...new Set((tags.length > 0 ? tags : fallbackTags).filter(Boolean)), - ].slice(0, 5); -} - -function clampText(value: string, maxLength: number) { - const normalized = value.trim().replace(/\s+/g, ' '); - if (!normalized) { - return ''; - } - if (normalized.length <= maxLength) { - return normalized; - } - return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`; -} - -function slugify(value: string) { - const ascii = value - .toLowerCase() - .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-') - .replace(/^-+|-+$/g, ''); - - return ascii ? ascii.slice(0, 24) : 'entry'; -} - -function createEntryId(prefix: string, label: string, index: number) { - return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`; -} - -function clampCustomWorldAffinity(value: number) { - return Math.max( - MIN_CUSTOM_WORLD_AFFINITY, - Math.min(MAX_CUSTOM_WORLD_AFFINITY, Math.round(value)), - ); -} - -function normalizeInitialAffinity(value: unknown, fallback: number) { - return typeof value === 'number' && Number.isFinite(value) - ? clampCustomWorldAffinity(value) - : fallback; -} - -function normalizeRarity( - value: unknown, - fallback: (typeof CUSTOM_WORLD_RARITIES)[number] = 'rare', -) { - const rarity = toText(value).toLowerCase(); - return CUSTOM_WORLD_RARITIES.includes( - rarity as (typeof CUSTOM_WORLD_RARITIES)[number], - ) - ? (rarity as (typeof CUSTOM_WORLD_RARITIES)[number]) - : fallback; -} - -function normalizeRoleItemCategory(value: unknown, fallback = '材料') { - const category = toText(value); - if ( - (CUSTOM_WORLD_ROLE_ITEM_CATEGORIES as readonly string[]).includes(category) - ) { - return category === '专属物' ? '专属物品' : category; - } - if (/武器|刀|剑|弓|枪|炮|锤/u.test(category)) return '武器'; - if (/甲|护|盾|衣|袍/u.test(category)) return '护甲'; - if (/饰|符|佩|坠|珠|印/u.test(category)) return '饰品'; - if (/药|食|补给|瓶|卷轴/u.test(category)) return '消耗品'; - if (/材|矿|木|石|鳞|骨/u.test(category)) return '材料'; - if (/秘|钥|图|册|录|卷/u.test(category)) return '稀有品'; - if (/信物|遗物|核心|母印|真符/u.test(category)) return '专属物品'; - return fallback; -} - -function splitNarrativeSentences(text: string) { - const normalized = text.replace(/\s+/g, ' ').trim(); - if (!normalized) { - return []; - } - const matches = normalized.match(/[^。!?!?]+[。!?!?]?/gu); - return (matches ?? [normalized]).map((item) => item.trim()).filter(Boolean); -} - -type CustomWorldRoleFallbackSource = Pick< - CustomWorldRoleProfile, - | 'name' - | 'title' - | 'role' - | 'description' - | 'backstory' - | 'personality' - | 'motivation' - | 'combatStyle' - | 'relationshipHooks' - | 'tags' ->; - -function buildFallbackBackstoryReveal( - source: CustomWorldRoleFallbackSource, -): CharacterBackstoryRevealConfig { - const normalizedBackstory = - source.backstory.trim() || `${source.name}对自己的过去始终有所保留。`; - const backstorySentences = splitNarrativeSentences(normalizedBackstory); - const backstoryLead = backstorySentences[0] ?? normalizedBackstory; - const backstoryDetail = - backstorySentences.slice(0, 2).join('') || normalizedBackstory; - const publicSummary = - source.description.trim() || clampText(normalizedBackstory, 42); - const fallbackContents = [ - source.description.trim() || backstoryLead, - backstoryDetail, - source.motivation.trim() - ? `${source.name}真正挂念的,是:${source.motivation.trim()}` - : `${source.name}的选择与“${clampText(backstoryLead, 24)}”直接相关。`, - source.personality.trim() - ? `${source.name}不会轻易说出的底色,是:${source.personality.trim()}` - : `${source.name}仍把最深的筹码藏在过去之中。`, - ]; - - return { - publicSummary, - privateChatUnlockAffinity: DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY, - chapters: AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.map( - (affinityRequired, index) => - ({ - id: createEntryId( - 'backstory-chapter', - `${source.name}-${CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? index + 1}`, - index, - ), - title: - CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? - `背景片段${index + 1}`, - affinityRequired, - teaser: clampText( - fallbackContents[index] ?? normalizedBackstory, - 22, - ), - content: clampText( - fallbackContents[index] ?? normalizedBackstory, - 72, - ), - contextSnippet: clampText( - `${source.name}的背景正在向“${fallbackContents[index] ?? normalizedBackstory}”这条线索展开。`, - 48, - ), - }) satisfies CharacterBackstoryChapter, - ), - }; -} - -function normalizeBackstoryReveal( - value: unknown, - fallbackSource: CustomWorldRoleFallbackSource, -) { - const fallback = buildFallbackBackstoryReveal(fallbackSource); - if (!value || typeof value !== 'object') { - return fallback; - } - - const item = value as Record; - const rawChapters = toRecordArray(item.chapters); - - return { - publicSummary: toText(item.publicSummary) || fallback.publicSummary, - privateChatUnlockAffinity: - typeof item.privateChatUnlockAffinity === 'number' && - Number.isFinite(item.privateChatUnlockAffinity) - ? clampCustomWorldAffinity(item.privateChatUnlockAffinity) - : fallback.privateChatUnlockAffinity, - chapters: AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.map( - (defaultAffinity, index) => { - const fallbackChapter = fallback.chapters[index]; - const rawChapter = rawChapters[index]; - return { - id: - (rawChapter && toText(rawChapter.id)) || - fallbackChapter?.id || - `backstory-chapter-${index + 1}`, - title: - (rawChapter && toText(rawChapter.title)) || - fallbackChapter?.title || - `背景片段${index + 1}`, - affinityRequired: - fallbackChapter?.affinityRequired ?? defaultAffinity, - teaser: - (rawChapter && toText(rawChapter.teaser)) || - fallbackChapter?.teaser || - '', - content: - (rawChapter && toText(rawChapter.content)) || - fallbackChapter?.content || - '', - contextSnippet: - (rawChapter && toText(rawChapter.contextSnippet)) || - fallbackChapter?.contextSnippet || - '', - } satisfies CharacterBackstoryChapter; - }, - ), - } satisfies CharacterBackstoryRevealConfig; -} - -function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) { - const skillNameSeed = source.title || source.role || source.name || '角色'; - const skillSummarySeed = - source.combatStyle || source.description || `${source.name}善于把握局势。`; - const motivationSeed = - source.motivation || source.personality || source.backstory; - - return [ - { - id: createEntryId('role-skill', `${skillNameSeed}-起手`, 0), - name: `${skillNameSeed}起手`, - summary: clampText(skillSummarySeed, 36), - style: '起手压制', - }, - { - id: createEntryId('role-skill', `${skillNameSeed}-机动`, 1), - name: `${skillNameSeed}变招`, - summary: clampText( - source.personality || `${source.name}习惯在试探中寻找破绽。`, - 36, - ), - style: '机动周旋', - }, - { - id: createEntryId('role-skill', `${skillNameSeed}-底牌`, 2), - name: `${skillNameSeed}底牌`, - summary: clampText( - motivationSeed || `${source.name}会在关键时刻亮出压箱手段。`, - 36, - ), - style: '爆发终结', - }, - ] satisfies CustomWorldRoleSkill[]; -} - -function normalizeRoleSkillList( - value: unknown, - fallbackSource: CustomWorldRoleFallbackSource, -) { - const normalized = toRecordArray(value) - .map((item, index) => { - const name = toText(item.name); - const summary = toText(item.summary) || toText(item.description); - const style = toText(item.style) || toText(item.category) || '常用'; - - return { - id: createEntryId('role-skill', name || style, index), - name, - summary, - style, - } satisfies CustomWorldRoleSkill; - }) - .filter((entry) => entry.name) - .slice(0, DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT); - - return normalized.length > 0 - ? normalized - : buildFallbackRoleSkills(fallbackSource); -} - -function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) { - const itemNameSeed = source.title || source.role || source.name || '角色'; - return [ - { - id: createEntryId('role-item', `${itemNameSeed}-1`, 0), - name: `${itemNameSeed}常备武具`, - category: '武器', - quantity: 1, - rarity: 'rare', - description: clampText( - source.combatStyle || `${source.name}随身携带的主要作战物件。`, - 36, - ), - tags: normalizeTags(source.tags, ['战斗', '随身']), - }, - { - id: createEntryId('role-item', `${itemNameSeed}-2`, 1), - name: `${itemNameSeed}补给包`, - category: '消耗品', - quantity: 2, - rarity: 'uncommon', - description: clampText( - source.personality || `${source.name}为了长期行动准备的基础补给。`, - 36, - ), - tags: normalizeTags(source.relationshipHooks, ['补给', '行动']), - }, - { - id: createEntryId('role-item', `${itemNameSeed}-3`, 2), - name: `${itemNameSeed}私人物件`, - category: '专属物品', - quantity: 1, - rarity: 'rare', - description: clampText( - source.backstory || - source.motivation || - `${source.name}不愿随意交出的信物。`, - 36, - ), - tags: normalizeTags( - [...source.tags, ...source.relationshipHooks], - ['信物', '线索'], - ), - }, - ] satisfies CustomWorldRoleInitialItem[]; -} - -function normalizeRoleInitialItemList( - value: unknown, - fallbackSource: CustomWorldRoleFallbackSource, -) { - const normalized = toRecordArray(value) - .map((item, index) => { - const name = toText(item.name); - return { - id: createEntryId('role-item', name, index), - name, - category: normalizeRoleItemCategory(item.category), - quantity: - typeof item.quantity === 'number' && Number.isFinite(item.quantity) - ? Math.max(1, Math.min(99, Math.round(item.quantity))) - : 1, - rarity: normalizeRarity(item.rarity, 'rare'), - description: toText(item.description), - tags: normalizeTags(item.tags), - } satisfies CustomWorldRoleInitialItem; - }) - .filter((entry) => entry.name) - .slice(0, DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT); - - return normalized.length > 0 - ? normalized - : buildFallbackRoleInitialItems(fallbackSource); -} - -function inferWorldTypeFromSetting(settingText: string): WorldType { - return /[仙灵修真飞升灵脉宗门法器天宫秘境道骨星舰]/u.test(settingText) - ? 'XIANXIA' - : 'WUXIA'; -} - -function normalizeWorldType(value: unknown, sourceText: string): WorldType { - const worldType = toText(value).toUpperCase(); - if (worldType === 'WUXIA' || worldType === 'XIANXIA') { - return worldType; - } - return inferWorldTypeFromSetting(sourceText); -} - -function buildSeedPhrase(settingText: string, fallback: string) { - const compact = settingText.replace(/\s+/g, '').trim(); - return compact ? compact.slice(0, 10) : fallback; -} - -function buildWorldName(settingText: string, worldType: WorldType) { - const seed = buildSeedPhrase(settingText, '新旅'); - const suffix = worldType === 'XIANXIA' ? '境' : '域'; - return `${seed}${suffix}`; -} - -function detectCustomWorldThemeMode(profile: { - settingText: string; - summary: string; - tone: string; - playerGoal: string; -}) { - const source = [ - profile.settingText, - profile.summary, - profile.tone, - profile.playerGoal, - ].join(' '); - - if (/[机关蒸汽齿轮工坊机巧城轨炮舰]/u.test(source)) return 'machina'; - if (/[海潮港湾船舟湖泊澜雾湾]/u.test(source)) return 'tide'; - if (/[裂缝裂界边境前线断层界桥灰域]/u.test(source)) return 'rift'; - if (/[修真仙灵宗门法器道脉秘境云阙]/u.test(source)) return 'arcane'; - if (/[江湖门派镖局朝廷刀剑侠客旧案]/u.test(source)) return 'martial'; - return 'mythic'; -} - -function sanitizeCampSeed(name: string) { - const normalized = name.trim().replace(/\s+/g, ''); - if (!normalized) { - return ''; - } - - const stripped = normalized.replace( - /(世界|江湖|边城|仙洲|仙境|灵境|界|录|域|境)$/u, - '', - ); - const seed = stripped || normalized; - - return seed.slice(0, Math.min(seed.length, 4)); -} - -function buildFallbackCampName(profile: { - name: string; - summary: string; - tone: string; - playerGoal: string; - settingText: string; -}) { - const seed = sanitizeCampSeed(profile.name) || '归途'; - const themeMode = detectCustomWorldThemeMode(profile); - - const suffixByMode = { - mythic: '归舍', - martial: '归舍', - arcane: '栖居', - machina: '整备居', - tide: '潮居', - rift: '界隙居所', - } as const; - - return `${seed}${suffixByMode[themeMode]}`; -} - -function buildFallbackCustomWorldCampScene(profile: { - name: string; - summary: string; - tone: string; - playerGoal: string; - settingText: string; -}): CustomWorldCampScene { - const fallbackName = buildFallbackCampName(profile); - const summaryLead = clampText(profile.summary, 24) || '前路尚未明朗'; - const goalLead = clampText(profile.playerGoal, 24) || '继续整理线索'; - const themeMode = detectCustomWorldThemeMode(profile); - - const descriptionByMode = { - mythic: `${fallbackName}是你在${profile.name}暂时安顿的归处,能整备行装、交换判断,并围绕“${goalLead}”继续启程。`, - martial: `${fallbackName}是你在${profile.name}暂时落脚的归处,能整备行装、收拢同伴,并朝“${goalLead}”继续动身。`, - arcane: `${fallbackName}是你在${profile.name}暂时栖身的居所,适合调息、整理法器,并围绕“${summaryLead}”交换判断。`, - machina: `${fallbackName}是你在${profile.name}的临时居所,能检修装备、补齐物资,并为下一段行动重新校准节奏。`, - tide: `${fallbackName}是你在${profile.name}靠潮歇息的住处,能安顿队伍、整理补给,并顺着“${goalLead}”继续前探。`, - rift: `${fallbackName}是你在${profile.name}勉强守住的一处居所,既能短暂停步,也能围绕“${summaryLead}”商量下一步去向。`, - } as const; - - return { - id: 'custom-scene-camp', - name: fallbackName, - description: descriptionByMode[themeMode], - dangerLevel: 'low', - sceneNpcIds: [], - connections: [], - narrativeResidues: null, - }; -} - -function buildTemplateWorldAttributeSchema(worldType: Exclude) { - const common = { - schemaVersion: 1, - generatedFrom: - worldType === 'XIANXIA' - ? { - worldType: 'XIANXIA' as const, - worldName: '仙侠', - settingSummary: '灵潮、宗门、禁制、秘境与道途交织。', - tone: '空灵、危险、带着灾变与大道压迫。', - conflictCore: '在裂变与因果之间稳住自我与道途。', - } - : { - worldType: 'WUXIA' as const, - worldName: '武侠', - settingSummary: '江湖、门派、旧案与人情纠葛并存。', - tone: '克制、紧张、讲究局势与心气。', - conflictCore: '在人情、威压与旧案之间立住自身。', - }, - }; - - if (worldType === 'XIANXIA') { - return { - id: 'schema:xianxia:v1', - worldId: 'XIANXIA', - schemaName: '灵界六轴', - ...common, - slots: [ - { - slotId: 'axis_a', - name: '道骨', - definition: '承载道压与高强度冲击的底子。', - positiveSignals: ['承压', '根基稳', '扛得住'], - negativeSignals: ['根基浅', '易溃', '承载不足'], - combatUseText: '扛住灵压、正面承受高强度对撞。', - socialUseText: '让人感到根基扎实,值得托付重事。', - explorationUseText: '承受秘境、禁制与裂隙带来的压迫。', - }, - { - slotId: 'axis_b', - name: '灵行', - definition: '位移、御空、转场、抢占天时地利的能力。', - positiveSignals: ['位移', '御空', '机动'], - negativeSignals: ['迟滞', '失位', '转场慢'], - combatUseText: '抢位、御空、快速重整战场位置。', - socialUseText: '反应轻快,擅长顺势接住局面的变化。', - explorationUseText: '穿越高危地形、裂隙、云海与复杂禁区。', - }, - { - slotId: 'axis_c', - name: '识海', - definition: '解析禁制、洞察因果、识破虚实的能力。', - positiveSignals: ['洞察', '解构', '看破'], - negativeSignals: ['迷失', '误判', '看不清'], - combatUseText: '识破术理、找出因果节点与破绽。', - socialUseText: '更容易辨认真话、虚言与隐藏动机。', - explorationUseText: '解读阵纹、禁制、旧史与环境异象。', - }, - { - slotId: 'axis_d', - name: '劫纹', - definition: '在高危变化中强行推进、改写局势的能力。', - positiveSignals: ['强推', '决断', '逆转'], - negativeSignals: ['畏缩', '迟疑', '不敢碰变局'], - combatUseText: '在高压窗口里压上去,逼出变化与突破。', - socialUseText: '在关键谈判中拍板,推动他人表态。', - explorationUseText: '面对异变与风险时敢于推进关键节点。', - }, - { - slotId: 'axis_e', - name: '心契', - definition: '与他者、器物、灵兽、誓约建立共鸣的能力。', - positiveSignals: ['共鸣', '结契', '安抚'], - negativeSignals: ['隔阂', '生硬', '难以共振'], - combatUseText: '与器物、灵兽、同伴形成协同与共鸣。', - socialUseText: '建立信任、誓约与更深层的关系连结。', - explorationUseText: '借由共鸣打开封印、回应遗物或安抚异兽。', - }, - { - slotId: 'axis_f', - name: '玄息', - definition: '循环灵息、稳住心神、让自身持续在线的能力。', - positiveSignals: ['稳态', '回转', '续航'], - negativeSignals: ['紊乱', '枯竭', '失衡'], - combatUseText: '维持灵息循环、拖住长线压力与消耗。', - socialUseText: '气息沉稳,不轻易乱阵脚或露出破绽。', - explorationUseText: '在漫长探索与灵潮侵蚀中维持可行动状态。', - }, - ] satisfies WorldAttributeSlot[], - } satisfies WorldAttributeSchema; - } - - return { - id: 'schema:wuxia:v1', - worldId: 'WUXIA', - schemaVersion: 1, - schemaName: '江湖六脉', - generatedFrom: common.generatedFrom, - slots: [ - { - slotId: 'axis_a', - name: '骨势', - definition: '扛压、顶冲、硬吃风险也不退的势头。', - positiveSignals: ['扛压', '硬桥硬马', '稳住正面'], - negativeSignals: ['虚浮', '怯退', '一碰就散'], - combatUseText: '顶住正面压力、换伤不退、撑住阵线。', - socialUseText: '在强压场面里不露怯,给人可靠或强硬之感。', - explorationUseText: '穿越险路、硬顶机关、承受高压环境。', - }, - { - slotId: 'axis_b', - name: '身法', - definition: '腾挪、抢位、换线、把握出手节奏的能力。', - positiveSignals: ['快', '轻灵', '抢位'], - negativeSignals: ['迟缓', '失位', '笨重'], - combatUseText: '切线换位、闪转腾挪、争夺先手。', - socialUseText: '应变快,擅长观察气口并顺势接话。', - explorationUseText: '攀越、潜入、追踪与复杂地形穿行。', - }, - { - slotId: 'axis_c', - name: '眼脉', - definition: '看破破绽、拆招、识局、看穿人心的能力。', - positiveSignals: ['识局', '洞察', '拆招'], - negativeSignals: ['迟钝', '误判', '看不透'], - combatUseText: '抓破绽、拆套路、找出最该切入的位置。', - socialUseText: '判断弦外之音、试探真假、识别来意。', - explorationUseText: '识破机关、辨认痕迹、看懂异状。', - }, - { - slotId: 'axis_d', - name: '心焰', - definition: '决断、压迫、胆气、在局面中立住自身意志的能力。', - positiveSignals: ['胆气', '决断', '压迫'], - negativeSignals: ['犹疑', '软弱', '易被动摇'], - combatUseText: '逼迫对手、强行推进、在关键时刻拍板。', - socialUseText: '立威、定调、在谈判里压住场子。', - explorationUseText: '在未知风险前保持决断,不被局势拖死。', - }, - { - slotId: 'axis_e', - name: '尘缘', - definition: '与人事、情面、承诺、牵引关系打交道的能力。', - positiveSignals: ['通人情', '会安抚', '懂交换'], - negativeSignals: ['生硬', '失礼', '不近人情'], - combatUseText: '借势协同、读懂同伴与对手的关系脉络。', - socialUseText: '安抚、求助、结盟、维系承诺与信任。', - explorationUseText: '从传闻、人脉和地方关系里打开线索。', - }, - { - slotId: 'axis_f', - name: '玄息', - definition: '调息、稳态、久战、把自身维持在可用状态的能力。', - positiveSignals: ['稳', '续战', '调息'], - negativeSignals: ['紊乱', '易崩', '续不上'], - combatUseText: '续战、回气、稳住节奏与状态。', - socialUseText: '遇事不乱,语气和姿态都更沉稳可信。', - explorationUseText: '长线跋涉、在恶劣环境下维持专注与状态。', - }, - ] satisfies WorldAttributeSlot[], - } satisfies WorldAttributeSchema; -} - -function generateWorldAttributeSchema(input: { - worldName: string; - settingText: string; - summary: string; - tone: string; - playerGoal: string; -}) { - const inferredWorldType = inferWorldTypeFromSetting(input.settingText); - const template = buildTemplateWorldAttributeSchema( - inferredWorldType === 'XIANXIA' ? 'XIANXIA' : 'WUXIA', - ); - - return { - ...template, - id: `schema:custom:${slugify(input.worldName)}`, - worldId: `custom:${input.worldName}`, - generatedFrom: { - worldType: 'CUSTOM', - worldName: input.worldName, - settingSummary: input.summary, - tone: input.tone, - conflictCore: input.playerGoal, - }, - } satisfies WorldAttributeSchema; -} - -function normalizeAttributeValues( - values: AttributeVector, - slotIds: readonly string[], - targetTotal = 360, -) { - const positiveValues = slotIds.map((slotId) => Math.max(0, values[slotId] ?? 0)); - const rawTotal = positiveValues.reduce((sum, value) => sum + value, 0); - const normalized = - rawTotal > 0 - ? positiveValues.map((value) => (value / rawTotal) * targetTotal) - : slotIds.map(() => targetTotal / Math.max(slotIds.length, 1)); - const rounded = normalized.map((value) => Math.max(0, Math.min(100, Math.round(value)))); - return Object.fromEntries( - slotIds.map((slotId, index) => [slotId, rounded[index] ?? 0]), - ) as AttributeVector; -} - -function ensureRoleAttributeProfile( - profile: Partial | null | undefined, - schema: WorldAttributeSchema, - fallbackValues: AttributeVector, -): RoleAttributeProfile { - const slotIds = schema.slots.map((slot) => slot.slotId); - const values = normalizeAttributeValues( - { - ...fallbackValues, - ...(profile?.values ?? {}), - }, - slotIds, - ); - const sortedSlots = [...schema.slots] - .map((slot) => ({ - slot, - value: values[slot.slotId] ?? 0, - })) - .sort((left, right) => right.value - left.value); - - return { - schemaId: profile?.schemaId ?? schema.id, - values, - topTraits: sortedSlots.slice(0, 2).map((entry) => entry.slot.name), - hiddenTraits: profile?.hiddenTraits ? [...profile.hiddenTraits] : undefined, - evidence: - profile?.evidence?.length - ? [...profile.evidence] - : sortedSlots.slice(0, 3).map((entry) => ({ - slotId: entry.slot.slotId, - reason: `${entry.slot.name}在当前画像中最突出。`, - })), - }; -} - -const AXIS_KEYWORD_RULES: Array<{ - slotId: string; - patterns: RegExp[]; - weight: number; -}> = [ - { slotId: 'axis_a', patterns: [/骨|甲|壳|岩|重|守|镇|顶|硬|锋|体/u], weight: 16 }, - { slotId: 'axis_b', patterns: [/身法|迅|影|风|闪|游|机动|追|步|轻灵/u], weight: 16 }, - { slotId: 'axis_c', patterns: [/识|眼|谋|算|阵|符|术|察|局|禁制/u], weight: 16 }, - { slotId: 'axis_d', patterns: [/心|焰|胆|威|压|怒|决|破|强推|意志/u], weight: 16 }, - { slotId: 'axis_e', patterns: [/缘|契|情|盟|商|医|助|信|交|誓/u], weight: 16 }, - { slotId: 'axis_f', patterns: [/息|稳|续|守|调|回|养|持久|回复|韧/u], weight: 16 }, -]; - -function buildDefaultAxisVector( - overrides: Partial>, -) { - return WORLD_ATTRIBUTE_SLOT_IDS.reduce((result, slotId) => { - result[slotId] = overrides[slotId] ?? 0; - return result; - }, {}); -} - -function buildRoleAttributeProfileFromTexts(params: { - entityId: string; - schema: WorldAttributeSchema; - textBlocks: Array; -}) { - const sourceText = params.textBlocks.filter(Boolean).join(' '); - const seed = buildDefaultAxisVector({ - axis_a: 58, - axis_b: 58, - axis_c: 58, - axis_d: 58, - axis_e: 58, - axis_f: 58, - }); - - AXIS_KEYWORD_RULES.forEach((rule) => { - const matches = rule.patterns.reduce( - (count, pattern) => count + (pattern.test(sourceText) ? 1 : 0), - 0, - ); - if (matches <= 0) { - return; - } - seed[rule.slotId] = (seed[rule.slotId] ?? 0) + rule.weight * matches; - }); - - return ensureRoleAttributeProfile( - { - schemaId: params.schema.id, - }, - params.schema, - seed, - ); -} - -function buildCustomWorldPlayableNpcAttributeProfile( - npc: CustomWorldPlayableNpc, - schema: WorldAttributeSchema, -) { - return buildRoleAttributeProfileFromTexts({ - entityId: npc.id, - schema, - textBlocks: [ - npc.title, - npc.role, - npc.description, - npc.backstory, - npc.personality, - npc.motivation, - npc.combatStyle, - ...(npc.relationshipHooks ?? []), - ...(npc.tags ?? []), - ], - }); -} - -function buildCustomWorldStoryNpcAttributeProfile( - npc: CustomWorldNpc, - schema: WorldAttributeSchema, -) { - return buildRoleAttributeProfileFromTexts({ - entityId: npc.id, - schema, - textBlocks: [ - npc.title, - npc.role, - npc.description, - npc.backstory, - npc.personality, - npc.motivation, - npc.combatStyle, - ...(npc.relationshipHooks ?? []), - ...(npc.tags ?? []), - ], - }); -} - -function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile { - const templateWorldType = inferWorldTypeFromSetting(settingText); - const name = buildWorldName(settingText, templateWorldType); - const subtitle = '前路未明'; - const summary = settingText.trim() - ? `这个世界围绕“${settingText.trim().slice(0, 28)}”展开。` - : '一个仍待展开的独立世界正在成形。'; - const tone = '未知、紧绷、仍在展开'; - const playerGoal = '查清眼前局势的关键矛盾,并守住仍值得相信的人与事'; - const camp = buildFallbackCustomWorldCampScene({ - name, - summary, - tone, - playerGoal, - settingText: settingText.trim(), - }); - - return { - id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`, - settingText: settingText.trim(), - name, - subtitle, - summary, - tone, - playerGoal, - cover: buildDefaultCustomWorldCover([]), - templateWorldType, - compatibilityTemplateWorldType: templateWorldType, - majorFactions: [], - coreConflicts: [summary], - attributeSchema: generateWorldAttributeSchema({ - worldName: name, - settingText: settingText.trim(), - summary, - tone, - playerGoal, - }), - playableNpcs: [], - storyNpcs: [], - items: [], - camp, - landmarks: [], - themePack: null, - storyGraph: null, - creatorIntent: null, - anchorPack: null, - lockState: normalizeCustomWorldLockState(null), - generationMode: 'full', - generationStatus: 'complete', - ownedSettingLayers: null, - scenarioPackId: null, - campaignPackId: null, - }; -} - -function normalizeRoleOutlineList( - value: unknown, - options: { - titleFallback: string; - defaultAffinity: number; - maxCount?: number; - }, -) { - const normalized = toRecordArray(value) - .map((item) => { - const name = toText(item.name); - const title = - toText(item.title) || toText(item.role) || options.titleFallback; - const role = toText(item.role) || title; - const relationshipHooks = normalizeTags( - item.relationshipHooks, - normalizeTags(item.tags), - ); - - return { - name, - title, - role, - description: - toText(item.description) || - clampText(`${name || title}在世界中以${role}身份活动。`, 36), - visualDescription: toText(item.visualDescription) || undefined, - actionDescription: toText(item.actionDescription) || undefined, - sceneVisualDescription: toText(item.sceneVisualDescription) || undefined, - initialAffinity: normalizeInitialAffinity( - item.initialAffinity, - options.defaultAffinity, - ), - relationshipHooks, - tags: normalizeTags(item.tags, relationshipHooks), - } satisfies CustomWorldGenerationRoleOutline; - }) - .filter((entry) => entry.name); - - return typeof options.maxCount === 'number' - ? normalized.slice(0, options.maxCount) - : normalized; -} - -function normalizeCampOutline( - value: unknown, - fallbackProfile: { - name: string; - summary: string; - tone: string; - playerGoal: string; - settingText: string; - }, -) { - const fallback = buildFallbackCustomWorldCampScene(fallbackProfile); - const item = - value && typeof value === 'object' - ? (value as Record) - : {}; - - return { - id: toText(item.id) || fallback.id, - name: toText(item.name) || fallback.name, - description: toText(item.description) || fallback.description, - visualDescription: toText(item.visualDescription) || undefined, - dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel, - imageSrc: toText(item.imageSrc) || undefined, - sceneNpcIds: toStringArray(item.sceneNpcIds), - connections: toRecordArray(item.connections) - .map((connection) => ({ - targetLandmarkName: - toText(connection.targetLandmarkName) || - toText(connection.target) || - toText(connection.sceneName), - relativePosition: - toText(connection.relativePosition) || - toText(connection.position) || - 'forward', - summary: - toText(connection.summary) || toText(connection.description), - })) - .filter((connection) => connection.targetLandmarkName), - }; -} - -function normalizeLandmarkOutlineList(value: unknown) { - return toRecordArray(value) - .map((item) => { - const name = toText(item.name); - return { - name, - description: - toText(item.description) || - clampText(`${name}暗藏新的局势变化。`, 40), - visualDescription: toText(item.visualDescription) || undefined, - dangerLevel: toText(item.dangerLevel) || 'medium', - sceneNpcNames: [ - ...toStringArray(item.sceneNpcNames), - ...toStringArray(item.npcs, 'name'), - ...toStringArray(item.sceneNpcs, 'name'), - ...toStringArray(item.npcNames), - ], - connections: toRecordArray(item.connections) - .map((connection) => ({ - targetLandmarkName: - toText(connection.targetLandmarkName) || - toText(connection.target) || - toText(connection.sceneName), - relativePosition: - toText(connection.relativePosition) || - toText(connection.position) || - 'forward', - summary: - toText(connection.summary) || toText(connection.description), - })) - .filter((connection) => connection.targetLandmarkName), - } satisfies CustomWorldGenerationLandmarkOutline; - }) - .filter((entry) => entry.name) - .slice(0, MIN_CUSTOM_WORLD_LANDMARK_COUNT); -} - -export function normalizeCustomWorldGenerationRoleOutlineBatch( - raw: unknown, - roleType: CustomWorldGenerationRoleBatchType, -) { - const item = - raw && typeof raw === 'object' ? (raw as Record) : {}; - const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'; - - return normalizeRoleOutlineList(item[key], { - titleFallback: '未定称号', - defaultAffinity: - roleType === 'playable' - ? DEFAULT_PLAYABLE_INITIAL_AFFINITY - : DEFAULT_STORY_NPC_INITIAL_AFFINITY, - }); -} - -export function normalizeCustomWorldGenerationLandmarkOutlineBatch( - raw: unknown, -) { - const item = - raw && typeof raw === 'object' ? (raw as Record) : {}; - return normalizeLandmarkOutlineList(item.landmarks); -} - -export function normalizeCustomWorldGenerationFramework( - raw: unknown, - settingText: string, -): CustomWorldGenerationFramework { - const fallback = buildBaseCustomWorldProfile(settingText); - if (!raw || typeof raw !== 'object') { - return { - settingText: fallback.settingText, - name: fallback.name, - subtitle: fallback.subtitle, - summary: fallback.summary, - tone: fallback.tone, - playerGoal: fallback.playerGoal, - templateWorldType: fallback.templateWorldType, - compatibilityTemplateWorldType: - fallback.compatibilityTemplateWorldType ?? fallback.templateWorldType, - majorFactions: [], - coreConflicts: [fallback.summary], - camp: { - name: fallback.camp?.name ?? '归舍', - description: fallback.camp?.description ?? '', - dangerLevel: fallback.camp?.dangerLevel ?? 'low', - }, - playableNpcs: [], - storyNpcs: [], - landmarks: [], - }; - } - - const item = raw as Record; - const worldSignalText = [ - settingText, - toText(item.subtitle), - toText(item.summary), - toText(item.tone), - toText(item.playerGoal), - ].join(' '); - const templateWorldType = normalizeWorldType( - item.templateWorldType, - worldSignalText, - ); - const name = - toText(item.name) || buildWorldName(settingText, templateWorldType); - - return { - settingText: settingText.trim(), - name, - subtitle: toText(item.subtitle) || fallback.subtitle, - summary: toText(item.summary) || fallback.summary, - tone: toText(item.tone) || fallback.tone, - playerGoal: toText(item.playerGoal) || fallback.playerGoal, - templateWorldType, - compatibilityTemplateWorldType: templateWorldType, - majorFactions: normalizeTags(item.majorFactions, []), - coreConflicts: normalizeTags(item.coreConflicts, [fallback.summary]), - camp: normalizeCampOutline(item.camp, { - name, - summary: toText(item.summary) || fallback.summary, - tone: toText(item.tone) || fallback.tone, - playerGoal: toText(item.playerGoal) || fallback.playerGoal, - settingText: settingText.trim(), - }), - playableNpcs: normalizeRoleOutlineList(item.playableNpcs, { - titleFallback: '未定称号', - defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY, - maxCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, - }), - storyNpcs: normalizeRoleOutlineList(item.storyNpcs, { - titleFallback: '未定称号', - defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY, - maxCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT, - }), - landmarks: normalizeLandmarkOutlineList(item.landmarks), - }; -} - -export function buildCustomWorldRawProfileFromFramework( - framework: CustomWorldGenerationFramework, -) { - return { - name: framework.name, - subtitle: framework.subtitle, - summary: framework.summary, - tone: framework.tone, - playerGoal: framework.playerGoal, - templateWorldType: framework.templateWorldType, - compatibilityTemplateWorldType: framework.compatibilityTemplateWorldType, - majorFactions: framework.majorFactions, - coreConflicts: framework.coreConflicts, - camp: { - name: framework.camp.name, - description: framework.camp.description, - dangerLevel: framework.camp.dangerLevel, - }, - playableNpcs: framework.playableNpcs.map((npc) => ({ - name: npc.name, - title: npc.title, - role: npc.role, - description: npc.description, - visualDescription: npc.visualDescription, - actionDescription: npc.actionDescription, - sceneVisualDescription: npc.sceneVisualDescription, - initialAffinity: npc.initialAffinity, - relationshipHooks: [...npc.relationshipHooks], - tags: [...npc.tags], - })), - storyNpcs: framework.storyNpcs.map((npc) => ({ - name: npc.name, - title: npc.title, - role: npc.role, - description: npc.description, - visualDescription: npc.visualDescription, - actionDescription: npc.actionDescription, - sceneVisualDescription: npc.sceneVisualDescription, - initialAffinity: npc.initialAffinity, - relationshipHooks: [...npc.relationshipHooks], - tags: [...npc.tags], - })), - landmarks: framework.landmarks.map((landmark) => ({ - name: landmark.name, - description: landmark.description, - visualDescription: landmark.visualDescription, - dangerLevel: landmark.dangerLevel, - sceneNpcNames: [...landmark.sceneNpcNames], - connections: landmark.connections.map((connection) => ({ - targetLandmarkName: connection.targetLandmarkName, - relativePosition: connection.relativePosition, - summary: connection.summary, - })), - })), - }; -} - -function normalizeRoleProfile( - item: Record, - index: number, - options: { - idPrefix: 'playable-npc' | 'story-npc'; - titleFallback: string; - defaultAffinity: number; - }, -) { - const name = toText(item.name); - const title = - toText(item.title) || toText(item.role) || options.titleFallback; - const role = toText(item.role) || title; - const relationshipHooks = normalizeTags( - item.relationshipHooks, - normalizeTags(item.tags), - ); - const normalizedRole = { - id: toText(item.id) || createEntryId(options.idPrefix, name, index), - name, - title, - role, - description: toText(item.description), - visualDescription: toText(item.visualDescription) || undefined, - actionDescription: toText(item.actionDescription) || undefined, - sceneVisualDescription: toText(item.sceneVisualDescription) || undefined, - backstory: toText(item.backstory), - personality: toText(item.personality), - motivation: toText(item.motivation) || toText(item.description), - combatStyle: toText(item.combatStyle), - initialAffinity: normalizeInitialAffinity( - item.initialAffinity, - options.defaultAffinity, - ), - relationshipHooks, - tags: normalizeTags(item.tags, relationshipHooks), - }; - - return { - ...normalizedRole, - backstoryReveal: normalizeBackstoryReveal( - item.backstoryReveal, - normalizedRole, - ), - skills: normalizeRoleSkillList(item.skills, normalizedRole), - initialItems: normalizeRoleInitialItemList(item.initialItems, normalizedRole), - imageSrc: toText(item.imageSrc) || undefined, - generatedVisualAssetId: toText(item.generatedVisualAssetId) || undefined, - generatedAnimationSetId: - toText(item.generatedAnimationSetId) || undefined, - animationMap: - item.animationMap && typeof item.animationMap === 'object' - ? (item.animationMap as Record) - : undefined, - narrativeProfile: - item.narrativeProfile && typeof item.narrativeProfile === 'object' - ? (item.narrativeProfile as CustomWorldRoleProfile['narrativeProfile']) - : null, - }; -} - -function normalizePlayableNpcList(value: unknown) { - return toRecordArray(value) - .map((item, index) => ({ - ...normalizeRoleProfile(item, index, { - idPrefix: 'playable-npc', - titleFallback: '未定称号', - defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY, - }), - templateCharacterId: toText(item.templateCharacterId) || undefined, - })) - .filter((entry) => entry.name) - .slice(0, MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT); -} - -function normalizeStoryNpcList(value: unknown) { - return toRecordArray(value) - .map( - (item, index) => - ({ - ...normalizeRoleProfile(item, index, { - idPrefix: 'story-npc', - titleFallback: '未定称号', - defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY, - }), - visual: - item.visual && typeof item.visual === 'object' - ? (item.visual as Record) - : undefined, - }) satisfies CustomWorldNpc, - ) - .filter((entry) => entry.name); -} - -function normalizeCustomWorldCoverCharacterRoleIds( - value: unknown, - playableNpcs: Array>, -) { - const availableIds = new Set( - playableNpcs.map((entry) => entry.id.trim()).filter(Boolean), - ); - const selectedIds = Array.isArray(value) - ? [ - ...new Set( - value - .map((entry) => toText(entry)) - .filter((entry) => entry && availableIds.has(entry)), - ), - ].slice(0, 3) - : []; - - if (selectedIds.length > 0) { - return selectedIds; - } - - return playableNpcs - .map((entry) => entry.id.trim()) - .filter(Boolean) - .slice(0, 3); -} - -function buildDefaultCustomWorldCover( - playableNpcs: Array>, -): CustomWorldCoverProfile { - return { - sourceType: 'default' as const, - imageSrc: null, - characterRoleIds: normalizeCustomWorldCoverCharacterRoleIds( - undefined, - playableNpcs, - ), - }; -} - -function normalizeCustomWorldCover( - value: unknown, - playableNpcs: Array>, -): CustomWorldCoverProfile { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return buildDefaultCustomWorldCover(playableNpcs); - } - - const item = value as Record; - const sourceType: CustomWorldCoverSourceType = - item.sourceType === 'uploaded' || item.sourceType === 'generated' - ? item.sourceType - : 'default'; - const imageSrc = toText(item.imageSrc) || null; - - if (sourceType !== 'default' && imageSrc) { - return { - sourceType, - imageSrc, - characterRoleIds: [], - }; - } - - return buildDefaultCustomWorldCover(playableNpcs); -} - -function normalizeItemList(value: unknown) { - return toRecordArray(value) - .map((item, index) => { - const name = toText(item.name); - const category = toText(item.category); - return { - id: toText(item.id) || createEntryId('item', name, index), - name, - category, - rarity: normalizeRarity(item.rarity, 'rare'), - description: toText(item.description), - tags: normalizeTags(item.tags), - } satisfies CustomWorldItem; - }) - .filter((entry) => entry.name && entry.category); -} - -function normalizeSceneActStageCoverage(value: unknown) { - const stageCoverage = Array.isArray(value) - ? value - .filter((entry): entry is string => typeof entry === 'string') - .map((entry) => entry.trim()) - .filter((entry): entry is SceneActBlueprint['stageCoverage'][number] => - SCENE_ACT_STAGES.has(entry as never), - ) - : []; - - return [...new Set(stageCoverage)]; -} - -function normalizeSceneActBlueprint( - value: unknown, - index: number, - sceneId: string, -): SceneActBlueprint | null { - const item = - value && typeof value === 'object' - ? (value as Record) - : null; - if (!item) { - return null; - } - - const encounterNpcIds = toStringArray(item.encounterNpcIds); - const stageCoverage = normalizeSceneActStageCoverage(item.stageCoverage); - const advanceRule = toText(item.advanceRule); - const title = toText(item.title); - const summary = toText(item.summary); - - if (!title && !summary && encounterNpcIds.length === 0) { - return null; - } - - return { - id: toText(item.id) || `saved-scene-act-${sceneId}-${index + 1}`, - sceneId, - title: title || `第 ${index + 1} 幕`, - summary: summary || title || `围绕${sceneId}继续推进`, - stageCoverage: - stageCoverage.length > 0 - ? stageCoverage - : index === 0 - ? ['opening'] - : ['climax', 'aftermath'], - backgroundImageSrc: toText(item.backgroundImageSrc) || undefined, - backgroundAssetId: toText(item.backgroundAssetId) || undefined, - encounterNpcIds, - primaryNpcId: toText(item.primaryNpcId) || encounterNpcIds[0] || '', - linkedThreadIds: toStringArray(item.linkedThreadIds), - advanceRule: SCENE_ACT_ADVANCE_RULES.has(advanceRule as never) - ? (advanceRule as SceneActBlueprint['advanceRule']) - : 'after_active_step_complete', - actGoal: toText(item.actGoal), - transitionHook: toText(item.transitionHook), - }; -} - -function normalizeSceneChapterBlueprints(value: unknown) { - if (!Array.isArray(value)) { - return null; - } - - const normalized = value - .filter( - (entry): entry is Record => - Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry), - ) - .map((entry, index) => { - const sceneId = toText(entry.sceneId); - if (!sceneId) { - return null; - } - - const acts = Array.isArray(entry.acts) - ? entry.acts - .map((act, actIndex) => - normalizeSceneActBlueprint(act, actIndex, sceneId), - ) - .filter((act): act is SceneActBlueprint => Boolean(act)) - : []; - - return { - id: toText(entry.id) || `saved-scene-chapter-${sceneId}-${index + 1}`, - sceneId, - title: toText(entry.title) || toText(entry.sceneName) || sceneId, - summary: toText(entry.summary), - linkedThreadIds: toStringArray(entry.linkedThreadIds), - linkedLandmarkIds: toStringArray(entry.linkedLandmarkIds), - acts, - } satisfies SceneChapterBlueprint; - }) - .filter((entry): entry is SceneChapterBlueprint => Boolean(entry)); - - return normalized.length > 0 ? normalized : null; -} - -function normalizeLandmarks(params: { - landmarks: Array>; - storyNpcs: CustomWorldNpc[]; -}) { - const storyNpcIdByName = new Map( - params.storyNpcs.map((npc) => [npc.name.trim(), npc.id] as const), - ); - const landmarkEntries = params.landmarks - .map((item, index) => ({ - id: toText(item.id) || createEntryId('landmark', toText(item.name), index), - name: toText(item.name), - description: toText(item.description), - visualDescription: toText(item.visualDescription) || undefined, - dangerLevel: toText(item.dangerLevel) || 'medium', - imageSrc: toText(item.imageSrc) || undefined, - sceneNpcIds: toStringArray(item.sceneNpcIds), - sceneNpcNames: [ - ...toStringArray(item.sceneNpcNames), - ...toStringArray(item.npcs, 'name'), - ...toStringArray(item.sceneNpcs, 'name'), - ...toStringArray(item.npcNames), - ], - connections: toRecordArray(item.connections).map((connection) => ({ - targetLandmarkId: toText(connection.targetLandmarkId), - targetLandmarkName: - toText(connection.targetLandmarkName) || - toText(connection.target) || - toText(connection.sceneName), - relativePosition: - toText(connection.relativePosition) || toText(connection.position), - summary: toText(connection.summary) || toText(connection.description), - })), - })) - .filter((entry) => entry.name); - - const landmarkIdByName = new Map( - landmarkEntries.map((landmark) => [landmark.name.trim(), landmark.id] as const), - ); - - return landmarkEntries.map((landmark) => { - const resolvedSceneNpcIds = [ - ...new Set( - [ - ...landmark.sceneNpcIds, - ...landmark.sceneNpcNames - .map((name) => storyNpcIdByName.get(name.trim()) ?? '') - .filter(Boolean), - ].filter(Boolean), - ), - ]; - - return { - id: landmark.id, - name: landmark.name, - description: landmark.description, - visualDescription: landmark.visualDescription, - dangerLevel: landmark.dangerLevel, - imageSrc: landmark.imageSrc, - sceneNpcIds: resolvedSceneNpcIds, - connections: landmark.connections - .map((connection) => ({ - targetLandmarkId: - connection.targetLandmarkId || - landmarkIdByName.get(connection.targetLandmarkName.trim()) || - '', - relativePosition: connection.relativePosition || 'forward', - summary: connection.summary, - })) - .filter((connection) => connection.targetLandmarkId), - }; - }); -} - -function normalizeCampScene( - value: unknown, - fallbackProfile: { - name: string; - summary: string; - tone: string; - playerGoal: string; - settingText: string; - }, -): CustomWorldCampScene { - const fallback = buildFallbackCustomWorldCampScene(fallbackProfile); - const item = - value && typeof value === 'object' - ? (value as Record) - : {}; - - return { - id: toText(item.id) || fallback.id, - name: toText(item.name) || fallback.name, - description: toText(item.description) || fallback.description, - visualDescription: toText(item.visualDescription) || undefined, - dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel, - imageSrc: toText(item.imageSrc) || undefined, - sceneNpcIds: toStringArray(item.sceneNpcIds), - connections: toRecordArray(item.connections) - .map((connection) => ({ - targetLandmarkId: toText(connection.targetLandmarkId), - relativePosition: - toText(connection.relativePosition) || toText(connection.position) || 'forward', - summary: toText(connection.summary) || toText(connection.description), - })) - .filter((connection) => connection.targetLandmarkId), - narrativeResidues: null, - }; -} - -export function normalizeCustomWorldProfile( - raw: unknown, - settingText: string, -): CustomWorldProfile { - const fallback = buildBaseCustomWorldProfile(settingText); - if (!raw || typeof raw !== 'object') { - return fallback; - } - - const item = raw as Record; - const worldSignalText = [ - settingText, - toText(item.subtitle), - toText(item.summary), - toText(item.tone), - toText(item.playerGoal), - ].join(' '); - const templateWorldType = normalizeWorldType( - item.templateWorldType, - worldSignalText, - ); - const name = - toText(item.name) || buildWorldName(settingText, templateWorldType); - const summary = toText(item.summary) || fallback.summary; - const tone = toText(item.tone) || fallback.tone; - const playerGoal = toText(item.playerGoal) || fallback.playerGoal; - const generatedAttributeSchema = generateWorldAttributeSchema({ - worldName: name, - settingText: settingText.trim(), - summary, - tone, - playerGoal, - }); - const playableNpcs = normalizePlayableNpcList(item.playableNpcs); - const storyNpcs = normalizeStoryNpcList(item.storyNpcs); - const landmarkDrafts = toRecordArray(item.landmarks); - const camp = normalizeCampScene(item.camp, { - name, - summary, - tone, - playerGoal, - settingText: settingText.trim(), - }); - const creatorIntent = normalizeCustomWorldCreatorIntent(item.creatorIntent); - - return { - id: - toText(item.id) || `custom-world-${Date.now().toString(36)}-${slugify(name)}`, - settingText: settingText.trim(), - name, - subtitle: toText(item.subtitle) || fallback.subtitle, - summary, - tone, - playerGoal, - cover: normalizeCustomWorldCover(item.cover, playableNpcs), - templateWorldType, - compatibilityTemplateWorldType: templateWorldType, - majorFactions: normalizeTags(item.majorFactions, []), - coreConflicts: normalizeTags(item.coreConflicts, [summary]), - attributeSchema: - item.attributeSchema && typeof item.attributeSchema === 'object' - ? generatedAttributeSchema - : generatedAttributeSchema, - playableNpcs, - storyNpcs, - items: normalizeItemList(item.items), - camp, - landmarks: normalizeLandmarks({ - landmarks: landmarkDrafts, - storyNpcs, - }), - themePack: - item.themePack && typeof item.themePack === 'object' - ? (item.themePack as CustomWorldProfile['themePack']) - : null, - storyGraph: - item.storyGraph && typeof item.storyGraph === 'object' - ? (item.storyGraph as CustomWorldProfile['storyGraph']) - : null, - anchorContent: - item.anchorContent && typeof item.anchorContent === 'object' - ? (item.anchorContent as Record) - : null, - creatorIntent, - anchorPack: - item.anchorPack && typeof item.anchorPack === 'object' - ? (item.anchorPack as CustomWorldProfile['anchorPack']) - : buildCustomWorldAnchorPackFromIntent(creatorIntent), - lockState: - item.lockState && typeof item.lockState === 'object' - ? normalizeCustomWorldLockState(item.lockState) - : deriveCustomWorldLockStateFromIntent(creatorIntent), - generationMode: - item.generationMode === 'fast' || item.generationMode === 'full' - ? item.generationMode - : fallback.generationMode, - generationStatus: - item.generationStatus === 'key_only' || item.generationStatus === 'complete' - ? item.generationStatus - : fallback.generationStatus, - ownedSettingLayers: - item.ownedSettingLayers && typeof item.ownedSettingLayers === 'object' - ? (item.ownedSettingLayers as Record) - : null, - knowledgeFacts: - Array.isArray(item.knowledgeFacts) - ? (item.knowledgeFacts as Array>) - : null, - threadContracts: - Array.isArray(item.threadContracts) - ? (item.threadContracts as Array>) - : null, - sceneChapterBlueprints: normalizeSceneChapterBlueprints( - item.sceneChapterBlueprints, - ), - scenarioPackId: toText(item.scenarioPackId) || null, - campaignPackId: toText(item.campaignPackId) || null, - }; -} - -function pickCyclic(items: readonly T[], index: number, label: string): T { - const item = items[index % items.length]; - if (item === undefined) { - throw new Error(`Missing ${label}`); - } - return item; -} - -export function buildCompiledCustomWorldProfile( - raw: unknown, - settingText: string, -): CustomWorldProfile { - const profile = normalizeCustomWorldProfile(raw, settingText); - const playableNpcs = profile.playableNpcs.map((npc, index) => { - const templateCharacterId = - npc.templateCharacterId ?? - pickCyclic( - PLAYABLE_TEMPLATE_CHARACTER_IDS, - index, - 'playable template character id', - ); - - return { - ...npc, - templateCharacterId, - attributeProfile: - npc.attributeProfile ?? - buildCustomWorldPlayableNpcAttributeProfile( - { - ...npc, - templateCharacterId, - }, - profile.attributeSchema, - ), - }; - }); - - const storyNpcs = profile.storyNpcs.map((npc) => ({ - ...npc, - attributeProfile: - npc.attributeProfile ?? - buildCustomWorldStoryNpcAttributeProfile(npc, profile.attributeSchema), - })); - - return { - ...profile, - playableNpcs, - storyNpcs, - scenarioPackId: - profile.scenarioPackId ?? `scenario-pack:${slugify(profile.name)}`, - campaignPackId: - profile.campaignPackId ?? `campaign-pack:${slugify(profile.name)}`, - }; -} - -function countUniqueNames(items: Array<{ name: string }>) { - return new Set(items.map((item) => item.name.trim()).filter(Boolean)).size; -} - -export function validateGeneratedCustomWorldProfile( - profile: CustomWorldProfile, -) { - const playableCount = countUniqueNames(profile.playableNpcs); - const landmarkCount = countUniqueNames(profile.landmarks); - - if (playableCount < MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT) { - throw new Error( - `自定义世界生成要求模型至少产出 ${MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT} 名可扮演角色。`, - ); - } - - if (landmarkCount < MIN_CUSTOM_WORLD_LANDMARK_COUNT) { - throw new Error( - `自定义世界生成要求至少产出 ${MIN_CUSTOM_WORLD_LANDMARK_COUNT} 个场景,当前仅返回 ${landmarkCount} 个。`, - ); - } - - const validStoryNpcIds = new Set(profile.storyNpcs.map((npc) => npc.id)); - const validLandmarkIds = new Set( - profile.landmarks.map((landmark) => landmark.id), - ); - - profile.landmarks.forEach((landmark) => { - const uniqueSceneNpcIds = [...new Set(landmark.sceneNpcIds)]; - if (uniqueSceneNpcIds.length < 3) { - throw new Error( - `场景「${landmark.name}」至少需要关联 3 个场景角色,当前仅有 ${uniqueSceneNpcIds.length} 个。`, - ); - } - if (uniqueSceneNpcIds.some((npcId) => !validStoryNpcIds.has(npcId))) { - throw new Error(`场景「${landmark.name}」引用了不存在的场景角色。`); - } - if (landmark.connections.length === 0) { - throw new Error(`场景「${landmark.name}」缺少可用的场景连接关系。`); - } - if ( - landmark.connections.some( - (connection) => - connection.targetLandmarkId === landmark.id || - !validLandmarkIds.has(connection.targetLandmarkId), - ) - ) { - throw new Error(`场景「${landmark.name}」存在无效的目标场景连接。`); - } - }); -} +/** + * 兼容期 façade: + * 旧调用暂时继续从 runtimeProfile.ts 导入,避免在工作包 G 首轮落地时放大迁移范围。 + * 新代码应逐步改走 runtime-profile/ 目录入口。 + */ +export * from './runtime-profile/index.js'; diff --git a/server-node/src/modules/inventory/inventoryStoryActionService.ts b/server-node/src/modules/inventory/inventoryStoryActionService.ts index 39670c2f..9e86d417 100644 --- a/server-node/src/modules/inventory/inventoryStoryActionService.ts +++ b/server-node/src/modules/inventory/inventoryStoryActionService.ts @@ -1,7 +1,7 @@ import type { RuntimeStoryActionRequest, RuntimeStoryPatch, -} from '../../../../packages/shared/src/contracts/story.js'; +} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; import { conflict, invalidRequest } from '../../errors.js'; import { getPlayerBuildDamageBreakdown, @@ -19,8 +19,8 @@ import { } from './inventoryMutationService.js'; import { replaceRuntimeSessionRawGameState, - type RuntimeSession, -} from '../story/runtimeSession.js'; +} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js'; +import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js'; const SUPPORTED_INVENTORY_STORY_FUNCTION_IDS = new Set([ 'equipment_equip', diff --git a/server-node/src/modules/inventory/npcInventoryStoryActionService.ts b/server-node/src/modules/inventory/npcInventoryStoryActionService.ts index 86f87d18..0b093731 100644 --- a/server-node/src/modules/inventory/npcInventoryStoryActionService.ts +++ b/server-node/src/modules/inventory/npcInventoryStoryActionService.ts @@ -1,7 +1,7 @@ import type { RuntimeStoryActionRequest, RuntimeStoryPatch, -} from '../../../../packages/shared/src/contracts/story.js'; +} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; import { conflict, invalidRequest } from '../../errors.js'; import { addInventoryItems, @@ -23,8 +23,8 @@ import { } from '../../bridges/legacyNpcTask6Bridge.js'; import { replaceRuntimeSessionRawGameState, - type RuntimeSession, -} from '../story/runtimeSession.js'; +} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js'; +import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js'; const SUPPORTED_NPC_INVENTORY_STORY_FUNCTION_IDS = new Set([ 'npc_gift', diff --git a/server-node/src/modules/npc/npcInteractionService.ts b/server-node/src/modules/npc/npcInteractionService.ts index 71ae0cf7..3e1acbca 100644 --- a/server-node/src/modules/npc/npcInteractionService.ts +++ b/server-node/src/modules/npc/npcInteractionService.ts @@ -1,6 +1,10 @@ -import type { RuntimeStoryPatch } from '../../../../packages/shared/src/contracts/story.js'; +import type { RuntimeStoryPatch } from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; import { conflict } from '../../errors.js'; import { resolveHostileBattleProfile } from '../progression/hostileProgressionService.js'; +import { + applyStoryChoiceToStanceProfile, +} from './npcTask6Primitives.js'; +import { markNpcFirstMeaningfulContactResolved } from '../runtime/runtimeNpcStatePrimitives.js'; import { MAX_TASK5_COMPANIONS, getEncounterNpcState, @@ -8,7 +12,7 @@ import { type RuntimeEncounter, type RuntimeNpcState, type RuntimeSession, -} from '../story/runtimeSession.js'; +} from '../rpg-runtime-story/RpgRuntimeSessionPrimitives.js'; type JsonRecord = Record; @@ -57,6 +61,158 @@ function isRecord(value: unknown): value is JsonRecord { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function readString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} + +function buildRecruitedCompanion( + session: RuntimeSession, + encounter: RuntimeEncounter, + npcState: RuntimeNpcState, +) { + const rawCompanionSource = isRecord(session.rawGameState.currentEncounter) + ? session.rawGameState.currentEncounter + : {}; + const maxHp = Math.max( + 1, + Math.round( + typeof rawCompanionSource.maxHp === 'number' && + Number.isFinite(rawCompanionSource.maxHp) + ? rawCompanionSource.maxHp + : 180, + ), + ); + const maxMana = Math.max( + 1, + Math.round( + typeof rawCompanionSource.maxMana === 'number' && + Number.isFinite(rawCompanionSource.maxMana) + ? rawCompanionSource.maxMana + : 999, + ), + ); + const skillCooldowns = Object.fromEntries( + Object.entries( + isRecord(rawCompanionSource.skillCooldowns) + ? rawCompanionSource.skillCooldowns + : {}, + ).map(([skillId, turns]) => [ + skillId, + typeof turns === 'number' && Number.isFinite(turns) + ? Math.max(0, Math.round(turns)) + : 0, + ]), + ); + + return { + npcId: encounter.id, + characterId: encounter.characterId ?? '', + joinedAtAffinity: npcState.affinity, + hp: maxHp, + maxHp, + mana: maxMana, + maxMana, + skillCooldowns, + animationState: readString(rawCompanionSource.animationState) || 'idle', + actionMode: readString(rawCompanionSource.actionMode) || 'idle', + offsetX: + typeof rawCompanionSource.offsetX === 'number' && + Number.isFinite(rawCompanionSource.offsetX) + ? rawCompanionSource.offsetX + : 0, + offsetY: + typeof rawCompanionSource.offsetY === 'number' && + Number.isFinite(rawCompanionSource.offsetY) + ? rawCompanionSource.offsetY + : 0, + transitionMs: + typeof rawCompanionSource.transitionMs === 'number' && + Number.isFinite(rawCompanionSource.transitionMs) + ? Math.max(0, Math.round(rawCompanionSource.transitionMs)) + : 0, + }; +} + +function upsertCompanion( + list: RuntimeSession['companions'], + companion: RuntimeSession['companions'][number], +) { + const next = [...list]; + const existingIndex = next.findIndex((item) => item.npcId === companion.npcId); + if (existingIndex >= 0) { + next[existingIndex] = companion; + return next; + } + + next.push(companion); + return next; +} + +function removeCompanion( + list: RuntimeSession['companions'], + npcId: string, +) { + return list.filter((item) => item.npcId !== npcId); +} + +function normalizeRoster( + roster: RuntimeSession['roster'], + activeCompanions: RuntimeSession['companions'], +) { + const activeIds = new Set(activeCompanions.map((companion) => companion.npcId)); + return roster.filter((companion) => !activeIds.has(companion.npcId)); +} + +function recruitCompanionToParty(params: { + session: RuntimeSession; + companion: RuntimeSession['companions'][number]; + releaseNpcId?: string | null; +}) { + const nextRosterWithoutRecruit = removeCompanion( + params.session.roster, + params.companion.npcId, + ); + + if ( + !params.releaseNpcId && + params.session.companions.length < MAX_TASK5_COMPANIONS + ) { + return { + companions: [...params.session.companions, params.companion], + roster: nextRosterWithoutRecruit, + releasedCompanion: null, + }; + } + + if (!params.releaseNpcId) { + throw conflict('队伍已满时必须明确指定一名离队同伴'); + } + + const replaceIndex = params.session.companions.findIndex( + (item) => item.npcId === params.releaseNpcId, + ); + if (replaceIndex < 0) { + throw conflict('指定的离队同伴不存在,无法完成换队招募'); + } + + const releasedCompanion = params.session.companions[replaceIndex]; + if (!releasedCompanion) { + throw conflict('指定的离队同伴不存在,无法完成换队招募'); + } + + const nextCompanions = [...params.session.companions]; + nextCompanions[replaceIndex] = params.companion; + + return { + companions: nextCompanions, + roster: normalizeRoster( + upsertCompanion(nextRosterWithoutRecruit, releasedCompanion), + nextCompanions, + ), + releasedCompanion, + }; +} + function buildBattleTarget( encounter: RuntimeEncounter, rawGameState: JsonRecord, @@ -92,6 +248,7 @@ function buildBattleTarget( export function resolveNpcInteraction( session: RuntimeSession, functionId: string, + payload?: JsonRecord, ): NpcInteractionResolution { const encounter = requireNpcEncounter(session); const npcState = requireNpcState(session, encounter); @@ -179,20 +336,29 @@ export function resolveNpcInteraction( if (npcState.affinity < 60) { throw conflict('当前关系还没达到招募阈值,暂时不能邀请入队'); } - if (session.companions.length >= MAX_TASK5_COMPANIONS) { - throw conflict('队伍已满,任务5首轮后端接口暂不处理换队逻辑'); - } - - setEncounterNpcState(session, { - ...npcState, + const releaseNpcId = readString(payload?.releaseNpcId) || null; + const recruitedCompanion = buildRecruitedCompanion( + session, + encounter, + npcState, + ); + const recruitmentResult = recruitCompanionToParty({ + session, + companion: recruitedCompanion, + releaseNpcId, + }); + const nextNpcState = { + ...markNpcFirstMeaningfulContactResolved(npcState), recruited: true, - firstMeaningfulContactResolved: true, - }); - session.companions.push({ - npcId: encounter.id, - characterId: encounter.characterId ?? '', - joinedAtAffinity: npcState.affinity, - }); + stanceProfile: applyStoryChoiceToStanceProfile( + npcState.stanceProfile, + 'npc_recruit', + { recruited: true }, + ), + }; + setEncounterNpcState(session, nextNpcState); + session.companions = recruitmentResult.companions; + session.roster = recruitmentResult.roster; session.currentEncounter = null; session.npcInteractionActive = false; session.currentNpcBattleMode = null; @@ -202,7 +368,9 @@ export function resolveNpcInteraction( return { actionText: `邀请${encounter.npcName}加入队伍`, - resultText: `${encounter.npcName}接受了你的邀请,正式进入了同行队伍。`, + resultText: recruitmentResult.releasedCompanion + ? `${encounter.npcName}接受了你的邀请,你先让一名当前同行暂时离队,把位置腾给了新的同行者。` + : `${encounter.npcName}接受了你的邀请,正式进入了同行队伍。`, patches: [ { type: 'status_changed', diff --git a/server-node/src/modules/quest/questRuntimeSignalService.ts b/server-node/src/modules/quest/questRuntimeSignalService.ts index 7fb86601..2ebec2c9 100644 --- a/server-node/src/modules/quest/questRuntimeSignalService.ts +++ b/server-node/src/modules/quest/questRuntimeSignalService.ts @@ -1,12 +1,12 @@ -import type { RuntimeBattlePresentation } from '../../../../packages/shared/src/contracts/story.js'; +import type { RuntimeBattlePresentation } from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; import { applyQuestSignal, normalizeQuestEntries, } from './questProgressionService.js'; import { replaceRuntimeSessionRawGameState, - type RuntimeSession, -} from '../story/runtimeSession.js'; +} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js'; +import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js'; type JsonRecord = Record; type RuntimeGameState = { diff --git a/server-node/src/modules/quest/questStoryActionService.ts b/server-node/src/modules/quest/questStoryActionService.ts index de2ce3ac..10098622 100644 --- a/server-node/src/modules/quest/questStoryActionService.ts +++ b/server-node/src/modules/quest/questStoryActionService.ts @@ -2,7 +2,7 @@ import type { RuntimeStoryOptionView, RuntimeStoryActionRequest, RuntimeStoryPatch, -} from '../../../../packages/shared/src/contracts/story.js'; +} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; import { buildExperienceGrantResultText, grantPlayerExperience, @@ -26,8 +26,8 @@ import { } from './questTask6Bridge.js'; import { replaceRuntimeSessionRawGameState, - type RuntimeSession, -} from '../story/runtimeSession.js'; +} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js'; +import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js'; const SUPPORTED_QUEST_STORY_FUNCTION_IDS = new Set([ 'npc_chat_quest_offer_abandon', diff --git a/server-node/src/modules/quest/runtimeQuestModule.ts b/server-node/src/modules/quest/runtimeQuestModule.ts index 66a97e2f..2d38377b 100644 --- a/server-node/src/modules/quest/runtimeQuestModule.ts +++ b/server-node/src/modules/quest/runtimeQuestModule.ts @@ -4,7 +4,7 @@ import { QUEST_OBJECTIVE_KINDS, QUEST_REWARD_THEMES, QUEST_URGENCY_LEVELS, -} from '../../../../packages/shared/src/contracts/story.js'; +} from '../../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js'; import { buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT, diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeOptionCompiler.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeOptionCompiler.ts new file mode 100644 index 00000000..a327b94c --- /dev/null +++ b/server-node/src/modules/rpg-runtime-story/RpgRuntimeOptionCompiler.ts @@ -0,0 +1,14 @@ +import { + buildAvailableOptions, + buildLegacyCurrentStory, + buildRuntimeViewModel, +} from './RpgRuntimeSessionDomain.js'; + +/** + * RPG runtime option / view model 编译入口。 + * 工作包 G 后所有可见 option 与 view model 都从新域目录输出。 + */ +export { buildAvailableOptions, buildRuntimeViewModel }; +export const buildRpgRuntimeAvailableOptions = buildAvailableOptions; +export const buildRpgRuntimeViewModel = buildRuntimeViewModel; +export const buildRpgRuntimeLegacyCurrentStory = buildLegacyCurrentStory; diff --git a/server-node/src/modules/story/runtimeSession.test.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.test.ts similarity index 94% rename from server-node/src/modules/story/runtimeSession.test.ts rename to server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.test.ts index 8ca7b522..87cba5c2 100644 --- a/server-node/src/modules/story/runtimeSession.test.ts +++ b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.test.ts @@ -3,9 +3,13 @@ import test from 'node:test'; import { buildAvailableOptions, +} from './RpgRuntimeOptionCompiler.js'; +import { buildLegacyCurrentStory, +} from './RpgRuntimeStoryPresentationCompiler.js'; +import { loadRuntimeSession, -} from './runtimeSession.ts'; +} from './RpgRuntimeSessionLoader.js'; function createNpcSnapshot() { return { diff --git a/server-node/src/modules/story/runtimeSession.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.ts similarity index 93% rename from server-node/src/modules/story/runtimeSession.ts rename to server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.ts index 7e38ce43..2e20ea51 100644 --- a/server-node/src/modules/story/runtimeSession.ts +++ b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.ts @@ -1,3 +1,7 @@ +/** + * RPG runtime session 编译主实现。 + * 工作包 G 把旧 `runtimeSession.ts` 的真实逻辑迁到这里,旧文件后续只保留兼容职责。 + */ import type { RuntimeStoryChoicePayload, RuntimeStoryEncounterViewModel, @@ -5,9 +9,9 @@ import type { RuntimeStoryOptionView, RuntimeStoryViewModel, Task5RuntimeOptionScope, -} from '../../../../packages/shared/src/contracts/story.js'; -import { TASK6_RUNTIME_FUNCTION_IDS } from '../../../../packages/shared/src/contracts/story.js'; -import type { SavedSnapshot } from '../../repositories/runtimeRepository.js'; +} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; +import { TASK6_RUNTIME_FUNCTION_IDS } from '../../../../packages/shared/src/contracts/rpgRuntimeStoryAction.js'; +import type { RpgRuntimeSavedSnapshot } from '../../repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js'; import { normalizeRuntimeEntityLevelProfile, type RuntimeEntityLevelProfile, @@ -75,6 +79,16 @@ export type RuntimeCompanion = { npcId: string; characterId: string; joinedAtAffinity: number; + hp: number; + maxHp: number; + mana: number; + maxMana: number; + skillCooldowns: Record; + animationState?: string; + actionMode?: string; + offsetX?: number; + offsetY?: number; + transitionMs?: number; }; type RuntimePlayerAttributes = { @@ -146,6 +160,7 @@ export type RuntimeSession = { playerMaxMana: number; npcStates: Record; companions: RuntimeCompanion[]; + roster: RuntimeCompanion[]; currentNpcBattleMode: 'fight' | 'spar' | null; currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null; }; @@ -511,6 +526,53 @@ function normalizeCompanion(value: unknown): RuntimeCompanion | null { npcId, characterId: readString(rawCompanion.characterId), joinedAtAffinity: Math.round(readNumber(rawCompanion.joinedAtAffinity, 0)), + hp: Math.max( + 0, + Math.round( + readNumber( + rawCompanion.hp, + readNumber(rawCompanion.maxHp, 1), + ), + ), + ), + maxHp: Math.max(1, Math.round(readNumber(rawCompanion.maxHp, 1))), + mana: Math.max( + 0, + Math.round( + readNumber( + rawCompanion.mana, + readNumber(rawCompanion.maxMana, 1), + ), + ), + ), + maxMana: Math.max(1, Math.round(readNumber(rawCompanion.maxMana, 1))), + skillCooldowns: Object.fromEntries( + Object.entries( + isObject(rawCompanion.skillCooldowns) + ? rawCompanion.skillCooldowns + : {}, + ).map(([skillId, turns]) => [ + skillId, + Math.max(0, Math.round(readNumber(turns, 0))), + ]), + ), + animationState: readString(rawCompanion.animationState) || undefined, + actionMode: readString(rawCompanion.actionMode) || undefined, + offsetX: + typeof rawCompanion.offsetX === 'number' && + Number.isFinite(rawCompanion.offsetX) + ? rawCompanion.offsetX + : undefined, + offsetY: + typeof rawCompanion.offsetY === 'number' && + Number.isFinite(rawCompanion.offsetY) + ? rawCompanion.offsetY + : undefined, + transitionMs: + typeof rawCompanion.transitionMs === 'number' && + Number.isFinite(rawCompanion.transitionMs) + ? Math.max(0, Math.round(rawCompanion.transitionMs)) + : undefined, }; } @@ -531,6 +593,15 @@ function normalizeCompanions(value: unknown) { .filter((entry): entry is RuntimeCompanion => Boolean(entry)); } +function normalizeRoster( + roster: RuntimeCompanion[], + companions: RuntimeCompanion[], +) { + const activeNpcIds = new Set(companions.map((companion) => companion.npcId)); + + return roster.filter((companion) => !activeNpcIds.has(companion.npcId)); +} + function normalizeHostileNpcs(value: unknown) { return readArray(value) .map((entry) => normalizeHostileNpc(entry)) @@ -944,7 +1015,7 @@ export function getEncounterKey(encounter: RuntimeEncounter) { } export function loadRuntimeSession( - snapshot: SavedSnapshot, + snapshot: RpgRuntimeSavedSnapshot, requestedSessionId: string, ): RuntimeSession { const rawGameState = isObject(snapshot.gameState) @@ -982,6 +1053,10 @@ export function loadRuntimeSession( ), npcStates: normalizeNpcStates(rawGameState.npcStates), companions: normalizeCompanions(rawGameState.companions), + roster: normalizeRoster( + normalizeCompanions(rawGameState.roster), + normalizeCompanions(rawGameState.companions), + ), currentNpcBattleMode: rawGameState.currentNpcBattleMode === 'fight' || rawGameState.currentNpcBattleMode === 'spar' @@ -1185,16 +1260,7 @@ export function buildAvailableOptions(session: RuntimeSession) { if (npcState && !npcState.recruited && npcState.affinity >= 60) { options.push( - buildOptionView( - session, - 'npc_recruit', - session.companions.length >= MAX_TASK5_COMPANIONS - ? { - disabled: true, - reason: '队伍已满,任务5首轮后端接口暂不处理换队逻辑。', - } - : {}, - ), + buildOptionView(session, 'npc_recruit'), ); } @@ -1328,6 +1394,7 @@ export function syncRawGameState(session: RuntimeSession) { session.rawGameState.playerMaxMana = session.playerMaxMana; session.rawGameState.npcStates = cloneJson(session.npcStates); session.rawGameState.companions = cloneJson(session.companions); + session.rawGameState.roster = cloneJson(session.roster); session.rawGameState.currentNpcBattleMode = session.currentNpcBattleMode; session.rawGameState.currentNpcBattleOutcome = session.currentNpcBattleOutcome; @@ -1367,6 +1434,7 @@ export function replaceRuntimeSessionRawGameState( session.playerMaxMana = refreshed.playerMaxMana; session.npcStates = refreshed.npcStates; session.companions = refreshed.companions; + session.roster = refreshed.roster; session.currentNpcBattleMode = refreshed.currentNpcBattleMode; session.currentNpcBattleOutcome = refreshed.currentNpcBattleOutcome; } diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionLoader.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionLoader.ts new file mode 100644 index 00000000..581774b6 --- /dev/null +++ b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionLoader.ts @@ -0,0 +1,13 @@ +import { + loadRuntimeSession, + type RuntimeSession, +} from './RpgRuntimeSessionDomain.js'; + +export type { RuntimeSession }; + +/** + * RPG runtime session loader 的主入口。 + * 工作包 G 把旧 `runtimeSession.ts` 的真实实现迁入新域后,这里负责承接稳定命名。 + */ +export { loadRuntimeSession }; +export const loadRpgRuntimeSession = loadRuntimeSession; diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionPrimitives.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionPrimitives.ts new file mode 100644 index 00000000..2fccbcc9 --- /dev/null +++ b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionPrimitives.ts @@ -0,0 +1,29 @@ +/** + * RPG runtime session 原子能力导出。 + * 这里集中输出运行时动作链直接依赖的 session 原语,避免再次回到旧热点文件取用。 + */ +export { + appendStoryHistory, + getEncounterKey, + getEncounterNpcState, + getPlayerCharacter, + getPlayerSkillCooldowns, + isCombatFunctionId, + isNpcFunctionId, + isStoryFunctionId, + isTask5FunctionId, + isTask6RuntimeFunctionId, + MAX_TASK5_COMPANIONS, + setEncounterNpcState, + syncRawGameState, + TASK6_DEFERRED_FUNCTION_IDS, +} from './RpgRuntimeSessionDomain.js'; + +export type { + RuntimeCompanion, + RuntimeEncounter, + RuntimeHostileNpc, + RuntimeNpcState, + RuntimeSession, + RuntimeStoryHistoryEntry, +} from './RpgRuntimeSessionDomain.js'; diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeSnapshotSync.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSnapshotSync.ts new file mode 100644 index 00000000..282dae39 --- /dev/null +++ b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSnapshotSync.ts @@ -0,0 +1,13 @@ +import { + replaceRuntimeSessionRawGameState, + syncRawGameState, +} from './RpgRuntimeSessionDomain.js'; + +/** + * RPG runtime snapshot 同步入口。 + * 工作包 G 后 rawGameState 的回写与替换都统一从新域目录输出。 + */ +export { replaceRuntimeSessionRawGameState, syncRawGameState }; +export const syncRpgRuntimeSnapshot = syncRawGameState; +export const replaceRpgRuntimeSessionRawGameState = + replaceRuntimeSessionRawGameState; diff --git a/server-node/src/modules/story/storyActionService.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts similarity index 96% rename from server-node/src/modules/story/storyActionService.ts rename to server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts index 86203ef3..b83dbb89 100644 --- a/server-node/src/modules/story/storyActionService.ts +++ b/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts @@ -1,3 +1,7 @@ +/** + * RPG runtime story 主链迁移后的真实动作/状态实现。 + * 工作包 G 完成后,运行时动作解析直接落在 RPG runtime story 新域。 + */ import type { RuntimeBattlePresentation, RuntimeStoryActionRequest, @@ -5,9 +9,9 @@ import type { RuntimeStoryOptionView, RuntimeStoryPatch, RuntimeStoryStateRequest, -} from '../../../../packages/shared/src/contracts/story.js'; +} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; import { conflict, invalidRequest } from '../../errors.js'; -import type { RuntimeRepositoryPort } from '../../repositories/runtimeRepository.js'; +import type { RpgRuntimeSnapshotRepositoryPort } from '../../repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js'; import type { UpstreamLlmClient } from '../../services/llmClient.js'; import { buildStrictNpcChatDialoguePrompt, @@ -39,21 +43,27 @@ import { resolveTreasureStoryAction, } from '../runtime-item/treasureStoryActionService.js'; import { - appendStoryHistory, buildAvailableOptions, - buildLegacyCurrentStory, buildRuntimeViewModel, +} from './RpgRuntimeOptionCompiler.js'; +import { + appendStoryHistory, getEncounterNpcState, isCombatFunctionId, isNpcFunctionId, isStoryFunctionId, isTask5FunctionId, - loadRuntimeSession, - type RuntimeSession, setEncounterNpcState, syncRawGameState, TASK6_DEFERRED_FUNCTION_IDS, -} from './runtimeSession.js'; +} from './RpgRuntimeSessionPrimitives.js'; +import { + buildLegacyCurrentStory, +} from './RpgRuntimeStoryPresentationCompiler.js'; +import { + loadRuntimeSession, + type RuntimeSession, +} from './RpgRuntimeSessionLoader.js'; type StoryResolution = { actionText: string; @@ -630,18 +640,23 @@ function normalizeIncomingSnapshot(snapshot: unknown) { } async function resolveSnapshotForRequest(params: { - runtimeRepository: RuntimeRepositoryPort; + snapshotRepository: RpgRuntimeSnapshotRepositoryPort; userId: string; snapshot?: unknown; }) { const incomingSnapshot = normalizeIncomingSnapshot(params.snapshot); if (incomingSnapshot) { return hydrateSavedSnapshot( - await params.runtimeRepository.putSnapshot(params.userId, incomingSnapshot), + await params.snapshotRepository.putSnapshot( + params.userId, + incomingSnapshot, + ), )!; } - const persistedSnapshot = await params.runtimeRepository.getSnapshot(params.userId); + const persistedSnapshot = await params.snapshotRepository.getSnapshot( + params.userId, + ); if (!persistedSnapshot) { throw conflict('运行时快照不存在,请先初始化并保存一次游戏'); } @@ -900,13 +915,13 @@ function resolveStoryFlowAction( } export async function resolveRuntimeStoryAction(params: { - runtimeRepository: RuntimeRepositoryPort; + snapshotRepository: RpgRuntimeSnapshotRepositoryPort; llmClient?: UpstreamLlmClient; userId: string; request: RuntimeStoryActionRequest; }) { const hydratedSnapshot = await resolveSnapshotForRequest({ - runtimeRepository: params.runtimeRepository, + snapshotRepository: params.snapshotRepository, userId: params.userId, snapshot: params.request.snapshot, }); @@ -969,7 +984,13 @@ export async function resolveRuntimeStoryAction(params: { : undefined, }); } else if (isNpcFunctionId(functionId)) { - resolution = resolveNpcInteraction(session, functionId); + resolution = resolveNpcInteraction( + session, + functionId, + isObject(params.request.action.payload) + ? params.request.action.payload + : undefined, + ); } else if (isSupportedInventoryStoryFunctionId(functionId)) { resolution = resolveInventoryStoryAction(session, params.request); } else if (isSupportedNpcInventoryStoryFunctionId(functionId)) { @@ -1074,7 +1095,7 @@ export async function resolveRuntimeStoryAction(params: { appendStoryHistory(session, actionText, historyResultText); syncRawGameState(session); - const persistedSnapshot = await params.runtimeRepository.putSnapshot( + const persistedSnapshot = await params.snapshotRepository.putSnapshot( params.userId, normalizeSavedSnapshotPayload({ savedAt: new Date().toISOString(), @@ -1109,14 +1130,14 @@ export async function resolveRuntimeStoryAction(params: { } export async function getRuntimeStoryState(params: { - runtimeRepository: RuntimeRepositoryPort; + snapshotRepository: RpgRuntimeSnapshotRepositoryPort; userId: string; sessionId: string; clientVersion?: number; snapshot?: RuntimeStoryStateRequest['snapshot']; }) { const hydratedSnapshot = await resolveSnapshotForRequest({ - runtimeRepository: params.runtimeRepository, + snapshotRepository: params.snapshotRepository, userId: params.userId, snapshot: params.snapshot, }); diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionService.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionService.ts new file mode 100644 index 00000000..475dee89 --- /dev/null +++ b/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionService.ts @@ -0,0 +1,10 @@ +import { + resolveRuntimeStoryAction, +} from './RpgRuntimeStoryActionDomain.js'; + +/** + * RPG runtime story 动作服务入口。 + * 工作包 G 后 runtime action 主链直接落到新域实现,不再继续桥接旧热点文件。 + */ +export { resolveRuntimeStoryAction }; +export const resolveRpgRuntimeStoryAction = resolveRuntimeStoryAction; diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryPresentationCompiler.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryPresentationCompiler.ts new file mode 100644 index 00000000..fd661f97 --- /dev/null +++ b/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryPresentationCompiler.ts @@ -0,0 +1,8 @@ +import { buildLegacyCurrentStory } from './RpgRuntimeSessionDomain.js'; + +/** + * RPG runtime story 展示兼容编译器。 + * 当前仍复用 legacy currentStory 投影规则,但真实落点已经切到新域目录。 + */ +export { buildLegacyCurrentStory }; +export const buildRpgRuntimeLegacyCurrentStory = buildLegacyCurrentStory; diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryStateService.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryStateService.ts new file mode 100644 index 00000000..71a1f8c0 --- /dev/null +++ b/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryStateService.ts @@ -0,0 +1,8 @@ +import { getRuntimeStoryState } from './RpgRuntimeStoryActionDomain.js'; + +/** + * RPG runtime story 状态读取入口。 + * 工作包 G 先把状态读取从旧热点文件迁到新域命名,再保持动作与状态的物理落点分离。 + */ +export { getRuntimeStoryState }; +export const getRpgRuntimeStoryState = getRuntimeStoryState; diff --git a/server-node/src/modules/rpg-runtime-story/index.ts b/server-node/src/modules/rpg-runtime-story/index.ts new file mode 100644 index 00000000..9497cded --- /dev/null +++ b/server-node/src/modules/rpg-runtime-story/index.ts @@ -0,0 +1,35 @@ +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'; diff --git a/server-node/src/modules/runtime-item/runtimeItemModule.ts b/server-node/src/modules/runtime-item/runtimeItemModule.ts index b03938c5..f3d223a3 100644 --- a/server-node/src/modules/runtime-item/runtimeItemModule.ts +++ b/server-node/src/modules/runtime-item/runtimeItemModule.ts @@ -1,7 +1,7 @@ import { RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES, RUNTIME_ITEM_TONE_VALUES, -} from '../../../../packages/shared/src/contracts/story.js'; +} from '../../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js'; import { buildRuntimeItemIntentPromptText, RUNTIME_ITEM_INTENT_SYSTEM_PROMPT, diff --git a/server-node/src/modules/runtime-item/treasureStoryActionService.ts b/server-node/src/modules/runtime-item/treasureStoryActionService.ts index 82b797ca..4160fcef 100644 --- a/server-node/src/modules/runtime-item/treasureStoryActionService.ts +++ b/server-node/src/modules/runtime-item/treasureStoryActionService.ts @@ -1,7 +1,7 @@ import type { RuntimeStoryActionRequest, RuntimeStoryPatch, -} from '../../../../packages/shared/src/contracts/story.js'; +} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; import { conflict, invalidRequest } from '../../errors.js'; import { addInventoryItems, @@ -14,8 +14,8 @@ import { import { buildBuildToast } from '../inventory/inventoryStoryActionService.js'; import { replaceRuntimeSessionRawGameState, - type RuntimeSession, -} from '../story/runtimeSession.js'; +} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js'; +import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js'; const SUPPORTED_TREASURE_STORY_FUNCTION_IDS = new Set([ 'treasure_inspect', diff --git a/server-node/src/observability.test.ts b/server-node/src/observability.test.ts index 3741ecff..19dd2c80 100644 --- a/server-node/src/observability.test.ts +++ b/server-node/src/observability.test.ts @@ -251,7 +251,7 @@ test('unauthorized request keeps request trace in error log and response header' assert.equal(response.status, 401); assert.equal(response.headers.get('x-request-id'), requestId); - assert.equal(payload.error.message, '缺少 Authorization Bearer Token'); + assert.equal(payload.error.message, '缺少登录凭证'); const errorLog = await waitForRecord( records, @@ -262,7 +262,7 @@ test('unauthorized request keeps request trace in error log and response header' assert.equal(errorLog.user_id, null); assert.equal( (errorLog.err as { message?: string } | undefined)?.message, - '缺少 Authorization Bearer Token', + '缺少登录凭证', ); assert.equal(errorLog.api_version, '2026-04-08'); assert.equal(errorLog.route_version, '2026-04-08'); diff --git a/server-node/src/prompts/chatPromptBuilders.ts b/server-node/src/prompts/chatPromptBuilders.ts index 6d80b488..3dab3f01 100644 --- a/server-node/src/prompts/chatPromptBuilders.ts +++ b/server-node/src/prompts/chatPromptBuilders.ts @@ -5,7 +5,7 @@ import type { NpcChatDialogueRequest, NpcChatTurnRequest, NpcRecruitDialogueRequest, -} from '../../../packages/shared/src/contracts/story.js'; +} from '../../../packages/shared/src/contracts/rpgRuntimeChat.js'; type JsonRecord = Record; diff --git a/server-node/src/repositories/RpgAgentSessionRepository.ts b/server-node/src/repositories/RpgAgentSessionRepository.ts new file mode 100644 index 00000000..b75f66b6 --- /dev/null +++ b/server-node/src/repositories/RpgAgentSessionRepository.ts @@ -0,0 +1,100 @@ +import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; +import type { AppDatabase } from '../db.js'; +import { + type RpgAgentSessionRow, +} from './rpgWorldRepositoryShared.js'; + +/** + * RPG Agent session 仓储最小读写接口。 + * 工作包 F 后续所有 session store / test stub 都应优先依赖这个领域端口。 + */ +export type RpgAgentSessionRepositoryPort = { + listSessions(userId: string): Promise; + getSession( + userId: string, + sessionId: string, + ): Promise; + upsertSession( + userId: string, + sessionId: string, + session: CustomWorldSessionRecord, + ): Promise; +}; + +/** + * RPG Agent session 仓储只负责 session 表读写,不再承担兼容补齐与快照派生。 + */ +export class RpgAgentSessionRepository implements RpgAgentSessionRepositoryPort { + constructor(private readonly db: AppDatabase) {} + + async listSessions(userId: string) { + const result = await this.db.query( + `SELECT payload_json AS payload, + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM custom_world_sessions + WHERE user_id = $1 + ORDER BY updated_at DESC`, + [userId], + ); + + return result.rows.map((row) => ({ + ...row.payload, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })); + } + + async getSession(userId: string, sessionId: string) { + const result = await this.db.query( + `SELECT payload_json AS payload, + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM custom_world_sessions + WHERE user_id = $1 AND session_id = $2`, + [userId, sessionId], + ); + const row = result.rows[0]; + + if (!row) { + return null; + } + + return { + ...row.payload, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + async upsertSession( + userId: string, + sessionId: string, + session: CustomWorldSessionRecord, + ) { + const payload = { + ...session, + sessionId, + } satisfies CustomWorldSessionRecord; + + await this.db.query( + `INSERT INTO custom_world_sessions ( + user_id, + session_id, + payload_json, + created_at, + updated_at + ) VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_id, session_id) DO UPDATE SET + payload_json = EXCLUDED.payload_json, + updated_at = EXCLUDED.updated_at`, + [userId, sessionId, payload, session.createdAt, session.updatedAt], + ); + + return { + ...payload, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + }; + } +} diff --git a/server-node/src/repositories/RpgWorldProfileRepository.ts b/server-node/src/repositories/RpgWorldProfileRepository.ts new file mode 100644 index 00000000..5fa2588d --- /dev/null +++ b/server-node/src/repositories/RpgWorldProfileRepository.ts @@ -0,0 +1,433 @@ +import type { + CustomWorldGalleryCard, + CustomWorldLibraryEntry, + CustomWorldProfileRecord, +} from '../../../packages/shared/src/contracts/runtime.js'; +import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js'; +import type { AppDatabase } from '../db.js'; +import { + MAX_RPG_WORLD_GALLERY_ENTRIES, + MAX_RPG_WORLD_PROFILE_ENTRIES, + normalizeStoredRpgWorldProfile, + toRpgWorldGalleryCard, + toRpgWorldLibraryEntry, + type RpgWorldGalleryRow, + type RpgWorldProfileRow, +} from './rpgWorldRepositoryShared.js'; + +/** + * RPG 世界 profile 领域端口。 + * works、library、gallery、脚本同步等链路后续统一依赖这个接口,而不是 RuntimeRepositoryPort。 + */ +export type RpgWorldProfileRepositoryPort = { + listOwnProfiles( + userId: string, + ): Promise[]>; + upsertOwnProfile( + userId: string, + profileId: string, + profile: Record, + authorDisplayName: string, + ): Promise<{ + entry: CustomWorldLibraryEntry; + entries: CustomWorldLibraryEntry[]; + }>; + syncProfileFromSnapshot( + userId: string, + profileId: string, + profile: Record, + syncedAt: string, + ): Promise; + softDeleteOwnProfile( + userId: string, + profileId: string, + ): Promise[]>; + publishOwnProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ): Promise<{ + entry: CustomWorldLibraryEntry; + entries: CustomWorldLibraryEntry[]; + } | null>; + unpublishOwnProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ): Promise<{ + entry: CustomWorldLibraryEntry; + entries: CustomWorldLibraryEntry[]; + } | null>; + listPublishedGallery(): Promise; + getPublishedGalleryDetail( + ownerUserId: string, + profileId: string, + ): Promise | null>; +}; + +/** + * RPG 世界 profile 仓储统一负责作品库、发布态与画廊读写。 + */ +export class RpgWorldProfileRepository implements RpgWorldProfileRepositoryPort { + constructor(private readonly db: AppDatabase) {} + + private async findOwnProfileEntry(userId: string, profileId: string) { + const result = await this.db.query( + `SELECT user_id AS "ownerUserId", + profile_id AS "profileId", + payload_json AS payload, + visibility, + published_at AS "publishedAt", + updated_at AS "updatedAt", + author_display_name AS "authorDisplayName", + world_name AS "worldName", + subtitle, + summary_text AS "summaryText", + cover_image_src AS "coverImageSrc", + theme_mode AS "themeMode", + playable_npc_count AS "playableNpcCount", + landmark_count AS "landmarkCount" + FROM custom_world_profiles + WHERE user_id = $1 + AND profile_id = $2 + AND deleted_at IS NULL`, + [userId, profileId], + ); + + const row = result.rows[0]; + return row ? toRpgWorldLibraryEntry(row) : null; + } + + async listOwnProfiles(userId: string) { + const result = await this.db.query( + `SELECT user_id AS "ownerUserId", + profile_id AS "profileId", + payload_json AS payload, + visibility, + published_at AS "publishedAt", + updated_at AS "updatedAt", + author_display_name AS "authorDisplayName", + world_name AS "worldName", + subtitle, + summary_text AS "summaryText", + cover_image_src AS "coverImageSrc", + theme_mode AS "themeMode", + playable_npc_count AS "playableNpcCount", + landmark_count AS "landmarkCount" + FROM custom_world_profiles + WHERE user_id = $1 + AND deleted_at IS NULL + ORDER BY updated_at DESC + LIMIT $2`, + [userId, MAX_RPG_WORLD_PROFILE_ENTRIES], + ); + + return result.rows.map((row) => toRpgWorldLibraryEntry(row)); + } + + async upsertOwnProfile( + userId: string, + profileId: string, + profile: Record, + authorDisplayName: string, + ) { + const payload = normalizeStoredRpgWorldProfile(profileId, profile); + const metadata = extractCustomWorldLibraryMetadata(payload); + const now = new Date().toISOString(); + + await this.db.query( + `INSERT INTO custom_world_profiles ( + user_id, + profile_id, + payload_json, + updated_at, + author_display_name, + world_name, + subtitle, + summary_text, + cover_image_src, + theme_mode, + playable_npc_count, + landmark_count + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (user_id, profile_id) DO UPDATE SET + payload_json = EXCLUDED.payload_json, + updated_at = EXCLUDED.updated_at, + deleted_at = NULL, + author_display_name = EXCLUDED.author_display_name, + world_name = EXCLUDED.world_name, + subtitle = EXCLUDED.subtitle, + summary_text = EXCLUDED.summary_text, + cover_image_src = EXCLUDED.cover_image_src, + theme_mode = EXCLUDED.theme_mode, + playable_npc_count = EXCLUDED.playable_npc_count, + landmark_count = EXCLUDED.landmark_count`, + [ + userId, + profileId, + payload, + now, + authorDisplayName || '玩家', + metadata.worldName, + metadata.subtitle, + metadata.summaryText, + metadata.coverImageSrc, + metadata.themeMode, + metadata.playableNpcCount, + metadata.landmarkCount, + ], + ); + + const entry = await this.findOwnProfileEntry(userId, profileId); + if (!entry) { + throw new Error('failed to resolve custom world after upsert'); + } + + return { + entry, + entries: await this.listOwnProfiles(userId), + }; + } + + async syncProfileFromSnapshot( + userId: string, + profileId: string, + profile: Record, + syncedAt: string, + ) { + const payload = normalizeStoredRpgWorldProfile(profileId, profile); + const metadata = extractCustomWorldLibraryMetadata(payload); + + await this.db.query( + `INSERT INTO custom_world_profiles ( + user_id, + profile_id, + payload_json, + updated_at, + author_display_name, + world_name, + subtitle, + summary_text, + cover_image_src, + theme_mode, + playable_npc_count, + landmark_count, + deleted_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULL) + ON CONFLICT (user_id, profile_id) DO UPDATE SET + payload_json = EXCLUDED.payload_json, + updated_at = EXCLUDED.updated_at, + deleted_at = NULL, + world_name = EXCLUDED.world_name, + subtitle = EXCLUDED.subtitle, + summary_text = EXCLUDED.summary_text, + cover_image_src = EXCLUDED.cover_image_src, + theme_mode = EXCLUDED.theme_mode, + playable_npc_count = EXCLUDED.playable_npc_count, + landmark_count = EXCLUDED.landmark_count`, + [ + userId, + profileId, + payload, + syncedAt, + '玩家', + metadata.worldName, + metadata.subtitle, + metadata.summaryText, + metadata.coverImageSrc, + metadata.themeMode, + metadata.playableNpcCount, + metadata.landmarkCount, + ], + ); + } + + async softDeleteOwnProfile(userId: string, profileId: string) { + const deletedAt = new Date().toISOString(); + await this.db.query( + `UPDATE custom_world_profiles + SET deleted_at = $1, + updated_at = $1, + visibility = 'draft', + published_at = NULL + WHERE user_id = $2 + AND profile_id = $3 + AND deleted_at IS NULL`, + [deletedAt, userId, profileId], + ); + + return this.listOwnProfiles(userId); + } + + async publishOwnProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ) { + const existingEntry = await this.findOwnProfileEntry(userId, profileId); + if (!existingEntry) { + return null; + } + + const payload = normalizeStoredRpgWorldProfile( + profileId, + existingEntry.profile, + ); + const metadata = extractCustomWorldLibraryMetadata(payload); + const now = new Date().toISOString(); + + await this.db.query( + `UPDATE custom_world_profiles + SET visibility = 'published', + published_at = $1, + updated_at = $1, + author_display_name = $2, + world_name = $3, + subtitle = $4, + summary_text = $5, + cover_image_src = $6, + theme_mode = $7, + playable_npc_count = $8, + landmark_count = $9 + WHERE user_id = $10 + AND profile_id = $11`, + [ + now, + authorDisplayName || '玩家', + metadata.worldName, + metadata.subtitle, + metadata.summaryText, + metadata.coverImageSrc, + metadata.themeMode, + metadata.playableNpcCount, + metadata.landmarkCount, + userId, + profileId, + ], + ); + + const entry = await this.findOwnProfileEntry(userId, profileId); + if (!entry) { + throw new Error('failed to resolve custom world after publish'); + } + + return { + entry, + entries: await this.listOwnProfiles(userId), + }; + } + + async unpublishOwnProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ) { + const existingEntry = await this.findOwnProfileEntry(userId, profileId); + if (!existingEntry) { + return null; + } + + const payload = normalizeStoredRpgWorldProfile( + profileId, + existingEntry.profile, + ); + const metadata = extractCustomWorldLibraryMetadata(payload); + const now = new Date().toISOString(); + + await this.db.query( + `UPDATE custom_world_profiles + SET visibility = 'draft', + published_at = NULL, + updated_at = $1, + author_display_name = $2, + world_name = $3, + subtitle = $4, + summary_text = $5, + cover_image_src = $6, + theme_mode = $7, + playable_npc_count = $8, + landmark_count = $9 + WHERE user_id = $10 + AND profile_id = $11`, + [ + now, + authorDisplayName || '玩家', + metadata.worldName, + metadata.subtitle, + metadata.summaryText, + metadata.coverImageSrc, + metadata.themeMode, + metadata.playableNpcCount, + metadata.landmarkCount, + userId, + profileId, + ], + ); + + const entry = await this.findOwnProfileEntry(userId, profileId); + if (!entry) { + throw new Error('failed to resolve custom world after unpublish'); + } + + return { + entry, + entries: await this.listOwnProfiles(userId), + }; + } + + async listPublishedGallery() { + const result = await this.db.query( + `SELECT user_id AS "ownerUserId", + profile_id AS "profileId", + visibility, + published_at AS "publishedAt", + updated_at AS "updatedAt", + author_display_name AS "authorDisplayName", + world_name AS "worldName", + subtitle, + summary_text AS "summaryText", + cover_image_src AS "coverImageSrc", + theme_mode AS "themeMode", + playable_npc_count AS "playableNpcCount", + landmark_count AS "landmarkCount" + FROM custom_world_profiles + WHERE visibility = 'published' + AND deleted_at IS NULL + ORDER BY published_at DESC, updated_at DESC + LIMIT $1`, + [MAX_RPG_WORLD_GALLERY_ENTRIES], + ); + + return result.rows.map((row) => toRpgWorldGalleryCard(row)); + } + + async getPublishedGalleryDetail(ownerUserId: string, profileId: string) { + const result = await this.db.query( + `SELECT user_id AS "ownerUserId", + profile_id AS "profileId", + payload_json AS payload, + visibility, + published_at AS "publishedAt", + updated_at AS "updatedAt", + author_display_name AS "authorDisplayName", + world_name AS "worldName", + subtitle, + summary_text AS "summaryText", + cover_image_src AS "coverImageSrc", + theme_mode AS "themeMode", + playable_npc_count AS "playableNpcCount", + landmark_count AS "landmarkCount" + FROM custom_world_profiles + WHERE user_id = $1 + AND profile_id = $2 + AND visibility = 'published' + AND deleted_at IS NULL`, + [ownerUserId, profileId], + ); + + const row = result.rows[0]; + return row ? toRpgWorldLibraryEntry(row) : null; + } +} diff --git a/server-node/src/repositories/rpg-entry/RpgEntryRepositories.test.ts b/server-node/src/repositories/rpg-entry/RpgEntryRepositories.test.ts new file mode 100644 index 00000000..e2237114 --- /dev/null +++ b/server-node/src/repositories/rpg-entry/RpgEntryRepositories.test.ts @@ -0,0 +1,241 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { RuntimeRepositoryPort } from '../runtimeRepository.js'; +import { RpgSaveArchiveRepository } from './RpgSaveArchiveRepository.js'; +import { RpgWorldLibraryRepository } from './RpgWorldLibraryRepository.js'; + +function createRuntimeRepositoryStub(): RuntimeRepositoryPort { + return { + async getSnapshot() { + return null; + }, + async putSnapshot(_userId, payload) { + return { + version: 1, + ...payload, + }; + }, + async getProfileDashboard() { + return { + walletBalance: 0, + totalPlayTimeMs: 0, + playedWorldCount: 0, + updatedAt: '2026-04-21T00:00:00.000Z', + }; + }, + async listProfileWalletLedger() { + return []; + }, + async getProfilePlayStats() { + return { + totalPlayTimeMs: 0, + playedWorks: [], + updatedAt: '2026-04-21T00:00:00.000Z', + }; + }, + async listProfileSaveArchives() { + return [ + { + worldKey: 'world-1', + ownerUserId: 'owner-1', + profileId: 'profile-1', + worldType: 'custom', + worldName: '潮影群岛', + subtitle: '港雾与旧航道', + summaryText: '最近一次继续游戏入口', + coverImageSrc: null, + lastPlayedAt: '2026-04-20T23:59:59.000Z', + }, + ]; + }, + async resumeProfileSaveArchive() { + return { + entry: { + worldKey: 'world-1', + ownerUserId: 'owner-1', + profileId: 'profile-1', + worldType: 'custom', + worldName: '潮影群岛', + subtitle: '港雾与旧航道', + summaryText: '最近一次继续游戏入口', + coverImageSrc: null, + lastPlayedAt: '2026-04-20T23:59:59.000Z', + }, + snapshot: { + version: 1, + savedAt: '2026-04-20T23:59:59.000Z', + bottomTab: 'adventure', + gameState: { currentScene: '潮影港' }, + currentStory: null, + }, + }; + }, + async deleteSnapshot() {}, + async getSettings() { + return { + musicVolume: 0.42, + platformTheme: 'light', + }; + }, + async putSettings(_userId, settings) { + return settings; + }, + async listCustomWorldProfiles() { + return [ + { + ownerUserId: 'owner-1', + profileId: 'profile-1', + profile: { + id: 'profile-1', + }, + visibility: 'published', + publishedAt: '2026-04-20T08:00:00.000Z', + updatedAt: '2026-04-20T08:00:00.000Z', + authorDisplayName: '造物者', + worldName: '潮影群岛', + subtitle: '港雾与旧航道', + summaryText: '一座在潮汐中漂移的群岛。', + coverImageSrc: '/covers/tide.png', + themeMode: 'tide', + playableNpcCount: 2, + landmarkCount: 3, + }, + ]; + }, + async listPlatformBrowseHistory() { + return []; + }, + async upsertPlatformBrowseHistoryEntries() { + return []; + }, + async clearPlatformBrowseHistory() {}, + async upsertCustomWorldProfile() { + return { + entry: { + ownerUserId: 'owner-1', + profileId: 'profile-1', + profile: { + id: 'profile-1', + }, + visibility: 'draft', + publishedAt: null, + updatedAt: '2026-04-20T08:00:00.000Z', + authorDisplayName: '造物者', + worldName: '潮影群岛', + subtitle: '港雾与旧航道', + summaryText: '一座在潮汐中漂移的群岛。', + coverImageSrc: '/covers/tide.png', + themeMode: 'tide', + playableNpcCount: 2, + landmarkCount: 3, + }, + entries: [], + }; + }, + async deleteCustomWorldProfile() { + return []; + }, + async listCustomWorldSessions() { + return []; + }, + async getCustomWorldSession() { + return null; + }, + async upsertCustomWorldSession(_userId, _sessionId, session) { + return session; + }, + async publishCustomWorldProfile() { + return { + entry: { + ownerUserId: 'owner-1', + profileId: 'profile-1', + profile: { + id: 'profile-1', + }, + visibility: 'published', + publishedAt: '2026-04-20T08:00:00.000Z', + updatedAt: '2026-04-20T08:00:00.000Z', + authorDisplayName: '造物者', + worldName: '潮影群岛', + subtitle: '港雾与旧航道', + summaryText: '一座在潮汐中漂移的群岛。', + coverImageSrc: '/covers/tide.png', + themeMode: 'tide', + playableNpcCount: 2, + landmarkCount: 3, + }, + entries: [], + }; + }, + async unpublishCustomWorldProfile() { + return null; + }, + async listPublishedCustomWorldGallery() { + return [ + { + ownerUserId: 'owner-1', + profileId: 'profile-1', + visibility: 'published', + publishedAt: '2026-04-20T08:00:00.000Z', + updatedAt: '2026-04-20T08:00:00.000Z', + authorDisplayName: '造物者', + worldName: '潮影群岛', + subtitle: '港雾与旧航道', + summaryText: '一座在潮汐中漂移的群岛。', + coverImageSrc: '/covers/tide.png', + themeMode: 'tide', + playableNpcCount: 2, + landmarkCount: 3, + }, + ]; + }, + async getPublishedCustomWorldGalleryDetail() { + return { + ownerUserId: 'owner-1', + profileId: 'profile-1', + profile: { + id: 'profile-1', + }, + visibility: 'published', + publishedAt: '2026-04-20T08:00:00.000Z', + updatedAt: '2026-04-20T08:00:00.000Z', + authorDisplayName: '造物者', + worldName: '潮影群岛', + subtitle: '港雾与旧航道', + summaryText: '一座在潮汐中漂移的群岛。', + coverImageSrc: '/covers/tide.png', + themeMode: 'tide', + playableNpcCount: 2, + landmarkCount: 3, + }; + }, + }; +} + +test('RpgSaveArchiveRepository 只承接继续游戏归档读取职责', async () => { + const repository = new RpgSaveArchiveRepository(createRuntimeRepositoryStub()); + + const archives = await repository.listProfileSaveArchives('user-1'); + const resumed = await repository.resumeProfileSaveArchive('user-1', 'world-1'); + + assert.equal(archives[0]?.worldName, '潮影群岛'); + assert.equal(resumed?.snapshot.bottomTab, 'adventure'); + assert.equal('getSnapshot' in repository, false); +}); + +test('RpgWorldLibraryRepository 独立承接作品库与广场读取职责', async () => { + const repository = new RpgWorldLibraryRepository(createRuntimeRepositoryStub()); + + const profiles = await repository.listCustomWorldProfiles('user-1'); + const gallery = await repository.listPublishedCustomWorldGallery(); + const detail = await repository.getPublishedCustomWorldGalleryDetail( + 'owner-1', + 'profile-1', + ); + + assert.equal(profiles[0]?.worldName, '潮影群岛'); + assert.equal(gallery[0]?.themeMode, 'tide'); + assert.equal(detail?.profileId, 'profile-1'); + assert.equal('listProfileSaveArchives' in repository, false); +}); diff --git a/server-node/src/repositories/rpg-entry/RpgSaveArchiveRepository.ts b/server-node/src/repositories/rpg-entry/RpgSaveArchiveRepository.ts new file mode 100644 index 00000000..4b79c559 --- /dev/null +++ b/server-node/src/repositories/rpg-entry/RpgSaveArchiveRepository.ts @@ -0,0 +1,36 @@ +import type { + RuntimeRepositoryPort, + SavedSnapshot, +} from '../runtimeRepository.js'; +import type { ProfileSaveArchiveSummary } from '../../../../packages/shared/src/contracts/runtime.js'; + +/** + * RPG 继续游戏归档仓储端口。 + * 当前仍由 runtimeRepository 提供真实实现,本文件只建立按领域命名的兼容入口。 + */ +export type RpgSaveArchiveRepositoryPort = Pick< + RuntimeRepositoryPort, + 'listProfileSaveArchives' | 'resumeProfileSaveArchive' +>; +export type RpgSaveArchiveSnapshot = SavedSnapshot; + +export class RpgSaveArchiveRepository implements RpgSaveArchiveRepositoryPort { + constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {} + + listProfileSaveArchives(userId: string): Promise { + return this.runtimeRepository.listProfileSaveArchives(userId); + } + + resumeProfileSaveArchive( + userId: string, + worldKey: string, + ): Promise< + | { + entry: ProfileSaveArchiveSummary; + snapshot: RpgSaveArchiveSnapshot; + } + | null + > { + return this.runtimeRepository.resumeProfileSaveArchive(userId, worldKey); + } +} diff --git a/server-node/src/repositories/rpg-entry/RpgWorldLibraryRepository.ts b/server-node/src/repositories/rpg-entry/RpgWorldLibraryRepository.ts new file mode 100644 index 00000000..e22ef70e --- /dev/null +++ b/server-node/src/repositories/rpg-entry/RpgWorldLibraryRepository.ts @@ -0,0 +1,92 @@ +import type { + CustomWorldGalleryCard, + CustomWorldLibraryEntry, + CustomWorldProfileRecord, +} from '../../../../packages/shared/src/contracts/runtime.js'; +import type { RuntimeRepositoryPort } from '../runtimeRepository.js'; + +/** + * RPG 世界库仓储端口。 + * 当前先桥接旧 runtimeRepository 中的世界库与广场读写,为工作包 H 的按域拆仓储提供命名骨架。 + */ +export type RpgWorldLibraryRepositoryPort = Pick< + RuntimeRepositoryPort, + | 'deleteCustomWorldProfile' + | 'getPublishedCustomWorldGalleryDetail' + | 'listCustomWorldProfiles' + | 'listPublishedCustomWorldGallery' + | 'publishCustomWorldProfile' + | 'unpublishCustomWorldProfile' + | 'upsertCustomWorldProfile' +>; + +export class RpgWorldLibraryRepository + implements RpgWorldLibraryRepositoryPort +{ + constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {} + + listCustomWorldProfiles( + userId: string, + ): Promise[]> { + return this.runtimeRepository.listCustomWorldProfiles(userId); + } + + upsertCustomWorldProfile( + userId: string, + profileId: string, + profile: Record, + authorDisplayName: string, + ) { + return this.runtimeRepository.upsertCustomWorldProfile( + userId, + profileId, + profile, + authorDisplayName, + ); + } + + deleteCustomWorldProfile( + userId: string, + profileId: string, + ): Promise[]> { + return this.runtimeRepository.deleteCustomWorldProfile(userId, profileId); + } + + publishCustomWorldProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ) { + return this.runtimeRepository.publishCustomWorldProfile( + userId, + profileId, + authorDisplayName, + ); + } + + unpublishCustomWorldProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ) { + return this.runtimeRepository.unpublishCustomWorldProfile( + userId, + profileId, + authorDisplayName, + ); + } + + listPublishedCustomWorldGallery(): Promise { + return this.runtimeRepository.listPublishedCustomWorldGallery(); + } + + getPublishedCustomWorldGalleryDetail( + ownerUserId: string, + profileId: string, + ): Promise | null> { + return this.runtimeRepository.getPublishedCustomWorldGalleryDetail( + ownerUserId, + profileId, + ); + } +} diff --git a/server-node/src/repositories/rpg-entry/index.ts b/server-node/src/repositories/rpg-entry/index.ts new file mode 100644 index 00000000..674edd5b --- /dev/null +++ b/server-node/src/repositories/rpg-entry/index.ts @@ -0,0 +1,8 @@ +export { + RpgSaveArchiveRepository, + type RpgSaveArchiveRepositoryPort, +} from './RpgSaveArchiveRepository.js'; +export { + RpgWorldLibraryRepository, + type RpgWorldLibraryRepositoryPort, +} from './RpgWorldLibraryRepository.js'; diff --git a/server-node/src/repositories/rpg-profile/RpgBrowseHistoryRepository.ts b/server-node/src/repositories/rpg-profile/RpgBrowseHistoryRepository.ts new file mode 100644 index 00000000..44a5e773 --- /dev/null +++ b/server-node/src/repositories/rpg-profile/RpgBrowseHistoryRepository.ts @@ -0,0 +1,42 @@ +import type { + PlatformBrowseHistoryEntry, + PlatformBrowseHistoryWriteEntry, +} from '../../../../packages/shared/src/contracts/runtime.js'; +import type { RuntimeRepositoryPort } from '../runtimeRepository.js'; + +/** + * RPG 浏览历史仓储端口。 + * 将继续游戏与平台浏览足迹独立命名,避免继续堆叠在资料看板仓储内。 + */ +export type RpgBrowseHistoryRepositoryPort = Pick< + RuntimeRepositoryPort, + | 'clearPlatformBrowseHistory' + | 'listPlatformBrowseHistory' + | 'upsertPlatformBrowseHistoryEntries' +>; + +export class RpgBrowseHistoryRepository + implements RpgBrowseHistoryRepositoryPort +{ + constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {} + + listPlatformBrowseHistory( + userId: string, + ): Promise { + return this.runtimeRepository.listPlatformBrowseHistory(userId); + } + + upsertPlatformBrowseHistoryEntries( + userId: string, + entries: PlatformBrowseHistoryWriteEntry[], + ): Promise { + return this.runtimeRepository.upsertPlatformBrowseHistoryEntries( + userId, + entries, + ); + } + + clearPlatformBrowseHistory(userId: string): Promise { + return this.runtimeRepository.clearPlatformBrowseHistory(userId); + } +} diff --git a/server-node/src/repositories/rpg-profile/RpgProfileDashboardRepository.ts b/server-node/src/repositories/rpg-profile/RpgProfileDashboardRepository.ts new file mode 100644 index 00000000..cc5ff5be --- /dev/null +++ b/server-node/src/repositories/rpg-profile/RpgProfileDashboardRepository.ts @@ -0,0 +1,49 @@ +import type { + ProfileDashboardSummary, + ProfilePlayStatsResponse, + ProfileWalletLedgerEntry, + RuntimeSettings, +} from '../../../../packages/shared/src/contracts/runtime.js'; +import type { RuntimeRepositoryPort } from '../runtimeRepository.js'; + +/** + * RPG profile 域仓储端口。 + * 当前以委托方式桥接旧 runtimeRepository,给后续按域仓储拆分保留稳定依赖面。 + */ +export type RpgProfileDashboardRepositoryPort = Pick< + RuntimeRepositoryPort, + | 'getProfileDashboard' + | 'getProfilePlayStats' + | 'getSettings' + | 'listProfileWalletLedger' + | 'putSettings' +>; + +export class RpgProfileDashboardRepository + implements RpgProfileDashboardRepositoryPort +{ + constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {} + + getProfileDashboard(userId: string): Promise { + return this.runtimeRepository.getProfileDashboard(userId); + } + + listProfileWalletLedger(userId: string): Promise { + return this.runtimeRepository.listProfileWalletLedger(userId); + } + + getProfilePlayStats(userId: string): Promise { + return this.runtimeRepository.getProfilePlayStats(userId); + } + + getSettings(userId: string): Promise { + return this.runtimeRepository.getSettings(userId); + } + + putSettings( + userId: string, + settings: RuntimeSettings, + ): Promise { + return this.runtimeRepository.putSettings(userId, settings); + } +} diff --git a/server-node/src/repositories/rpg-profile/RpgProfileRepositories.test.ts b/server-node/src/repositories/rpg-profile/RpgProfileRepositories.test.ts new file mode 100644 index 00000000..bbb4cf3f --- /dev/null +++ b/server-node/src/repositories/rpg-profile/RpgProfileRepositories.test.ts @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { RuntimeRepositoryPort } from '../runtimeRepository.js'; +import { RpgBrowseHistoryRepository } from './RpgBrowseHistoryRepository.js'; +import { RpgProfileDashboardRepository } from './RpgProfileDashboardRepository.js'; + +function createRuntimeRepositoryStub(): RuntimeRepositoryPort { + return { + async getSnapshot() { + return null; + }, + async putSnapshot(_userId, payload) { + return { + version: 1, + ...payload, + }; + }, + async getProfileDashboard() { + return { + walletBalance: 0, + totalPlayTimeMs: 0, + playedWorldCount: 0, + updatedAt: '2026-04-21T00:00:00.000Z', + }; + }, + async listProfileWalletLedger() { + return []; + }, + async getProfilePlayStats() { + return { + totalPlayTimeMs: 0, + playedWorks: [], + updatedAt: '2026-04-21T00:00:00.000Z', + }; + }, + async listProfileSaveArchives() { + return []; + }, + async resumeProfileSaveArchive() { + return null; + }, + async deleteSnapshot() {}, + async getSettings() { + return { + musicVolume: 0.5, + platformTheme: 'light', + }; + }, + async putSettings(_userId, settings) { + return settings; + }, + async listCustomWorldProfiles() { + return []; + }, + async listPlatformBrowseHistory() { + return [ + { + ownerUserId: 'owner-1', + profileId: 'profile-1', + worldName: '雾港', + subtitle: '沿海试炼', + summaryText: '最近访问', + coverImageSrc: null, + themeMode: 'mythic', + authorDisplayName: '测试者', + visitedAt: '2026-04-21T00:00:00.000Z', + }, + ]; + }, + async upsertPlatformBrowseHistoryEntries(_userId, entries) { + return entries.map((entry) => ({ + ownerUserId: entry.ownerUserId, + profileId: entry.profileId, + worldName: entry.worldName, + subtitle: entry.subtitle ?? '', + summaryText: entry.summaryText ?? '', + coverImageSrc: entry.coverImageSrc ?? null, + themeMode: entry.themeMode ?? 'mythic', + authorDisplayName: entry.authorDisplayName ?? '玩家', + visitedAt: entry.visitedAt ?? '2026-04-21T00:00:00.000Z', + })); + }, + async clearPlatformBrowseHistory() {}, + async upsertCustomWorldProfile() { + return { + entry: {} as never, + entries: [], + }; + }, + async deleteCustomWorldProfile() { + return []; + }, + async listCustomWorldSessions() { + return []; + }, + async getCustomWorldSession() { + return null; + }, + async upsertCustomWorldSession(_userId, _sessionId, session) { + return session; + }, + async publishCustomWorldProfile() { + return null; + }, + async unpublishCustomWorldProfile() { + return null; + }, + async listPublishedCustomWorldGallery() { + return []; + }, + async getPublishedCustomWorldGalleryDetail() { + return null; + }, + }; +} + +test('RpgProfileDashboardRepository 只暴露资料看板域方法', async () => { + const repository = new RpgProfileDashboardRepository( + createRuntimeRepositoryStub(), + ); + + const dashboard = await repository.getProfileDashboard('user-1'); + const playStats = await repository.getProfilePlayStats('user-1'); + const settings = await repository.getSettings('user-1'); + + assert.equal(dashboard.playedWorldCount, 0); + assert.equal(playStats.playedWorks.length, 0); + assert.equal(settings.platformTheme, 'light'); + assert.equal('listPlatformBrowseHistory' in repository, false); +}); + +test('RpgBrowseHistoryRepository 独立承接浏览历史读写,不再混入资料看板仓储', async () => { + const repository = new RpgBrowseHistoryRepository(createRuntimeRepositoryStub()); + + const history = await repository.listPlatformBrowseHistory('user-1'); + const updated = await repository.upsertPlatformBrowseHistoryEntries('user-1', [ + { + ownerUserId: 'owner-2', + profileId: 'profile-2', + worldName: '盐雾镇', + subtitle: '盐路补给点', + summaryText: '测试写入浏览历史', + coverImageSrc: null, + themeMode: 'mythic', + authorDisplayName: '测试者二号', + visitedAt: '2026-04-21T01:00:00.000Z', + }, + ]); + + assert.equal(history[0]?.worldName, '雾港'); + assert.equal(updated[0]?.profileId, 'profile-2'); + assert.equal('getProfileDashboard' in repository, false); +}); diff --git a/server-node/src/repositories/rpg-profile/index.ts b/server-node/src/repositories/rpg-profile/index.ts new file mode 100644 index 00000000..9abdcd25 --- /dev/null +++ b/server-node/src/repositories/rpg-profile/index.ts @@ -0,0 +1,8 @@ +export { + RpgBrowseHistoryRepository, + type RpgBrowseHistoryRepositoryPort, +} from './RpgBrowseHistoryRepository.js'; +export { + RpgProfileDashboardRepository, + type RpgProfileDashboardRepositoryPort, +} from './RpgProfileDashboardRepository.js'; diff --git a/server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.test.ts b/server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.test.ts new file mode 100644 index 00000000..f1d01b08 --- /dev/null +++ b/server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.test.ts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { RuntimeRepositoryPort } from '../runtimeRepository.js'; +import { RpgRuntimeSnapshotRepository } from './RpgRuntimeSnapshotRepository.js'; + +function createRuntimeRepositoryStub(): RuntimeRepositoryPort { + const deletedUserIds: string[] = []; + + return { + async getSnapshot(userId) { + return { + version: 2, + savedAt: '2026-04-21T00:00:00.000Z', + bottomTab: 'adventure', + gameState: { + owner: userId, + }, + currentStory: null, + }; + }, + async putSnapshot(_userId, payload) { + return { + version: 2, + ...payload, + }; + }, + async getProfileDashboard() { + return { + walletBalance: 0, + totalPlayTimeMs: 0, + playedWorldCount: 0, + updatedAt: '2026-04-21T00:00:00.000Z', + }; + }, + async listProfileWalletLedger() { + return []; + }, + async getProfilePlayStats() { + return { + totalPlayTimeMs: 0, + playedWorks: [], + updatedAt: '2026-04-21T00:00:00.000Z', + }; + }, + async listProfileSaveArchives() { + return []; + }, + async resumeProfileSaveArchive() { + return null; + }, + async deleteSnapshot(userId) { + deletedUserIds.push(userId); + }, + async getSettings() { + return { + musicVolume: 0.42, + platformTheme: 'light', + }; + }, + async putSettings(_userId, settings) { + return settings; + }, + async listCustomWorldProfiles() { + return []; + }, + async listPlatformBrowseHistory() { + return []; + }, + async upsertPlatformBrowseHistoryEntries() { + return []; + }, + async clearPlatformBrowseHistory() {}, + async upsertCustomWorldProfile() { + return { + entry: {} as never, + entries: [], + }; + }, + async deleteCustomWorldProfile() { + return []; + }, + async listCustomWorldSessions() { + return []; + }, + async getCustomWorldSession() { + return null; + }, + async upsertCustomWorldSession(_userId, _sessionId, session) { + return session; + }, + async publishCustomWorldProfile() { + return null; + }, + async unpublishCustomWorldProfile() { + return null; + }, + async listPublishedCustomWorldGallery() { + return []; + }, + async getPublishedCustomWorldGalleryDetail() { + return null; + }, + }; +} + +test('RpgRuntimeSnapshotRepository 独立承接 snapshot 读写与删除职责', async () => { + const runtimeRepository = createRuntimeRepositoryStub(); + const repository = new RpgRuntimeSnapshotRepository(runtimeRepository); + + const snapshot = await repository.getSnapshot('user-7'); + const saved = await repository.putSnapshot('user-7', { + savedAt: '2026-04-21T01:00:00.000Z', + bottomTab: 'inventory', + gameState: { + owner: 'user-7', + currentScene: '雾港', + }, + currentStory: null, + }); + await repository.deleteSnapshot('user-7'); + + assert.equal(snapshot?.gameState.owner, 'user-7'); + assert.equal(saved.bottomTab, 'inventory'); + assert.equal('listProfileSaveArchives' in repository, false); +}); diff --git a/server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.ts b/server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.ts new file mode 100644 index 00000000..73c6fffb --- /dev/null +++ b/server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.ts @@ -0,0 +1,35 @@ +import type { + RuntimeRepositoryPort, + SavedSnapshot, +} from '../runtimeRepository.js'; + +/** + * RPG runtime 快照仓储端口。 + * 工作包 A 先把 snapshot 领域从大仓储中抽出独立命名入口,真实读写仍委托现有 runtimeRepository。 + */ +export type RpgRuntimeSnapshotRepositoryPort = Pick< + RuntimeRepositoryPort, + 'deleteSnapshot' | 'getSnapshot' | 'putSnapshot' +>; +export type RpgRuntimeSavedSnapshot = SavedSnapshot; + +export class RpgRuntimeSnapshotRepository + implements RpgRuntimeSnapshotRepositoryPort +{ + constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {} + + getSnapshot(userId: string): Promise { + return this.runtimeRepository.getSnapshot(userId); + } + + putSnapshot( + userId: string, + payload: Omit, + ): Promise { + return this.runtimeRepository.putSnapshot(userId, payload); + } + + deleteSnapshot(userId: string): Promise { + return this.runtimeRepository.deleteSnapshot(userId); + } +} diff --git a/server-node/src/repositories/rpg-runtime/index.ts b/server-node/src/repositories/rpg-runtime/index.ts new file mode 100644 index 00000000..759f002b --- /dev/null +++ b/server-node/src/repositories/rpg-runtime/index.ts @@ -0,0 +1,4 @@ +export { + RpgRuntimeSnapshotRepository, + type RpgRuntimeSnapshotRepositoryPort, +} from './RpgRuntimeSnapshotRepository.js'; diff --git a/server-node/src/repositories/rpgWorldRepositoryShared.ts b/server-node/src/repositories/rpgWorldRepositoryShared.ts new file mode 100644 index 00000000..639255f1 --- /dev/null +++ b/server-node/src/repositories/rpgWorldRepositoryShared.ts @@ -0,0 +1,116 @@ +import type { QueryResultRow } from 'pg'; + +import type { + CustomWorldProfileRecord, +} from '../../../packages/shared/src/contracts/runtime.js'; +import { + type CustomWorldGalleryCard, + type CustomWorldLibraryEntry, + type CustomWorldPublicationStatus, + type CustomWorldSessionRecord, +} from '../../../packages/shared/src/contracts/runtime.js'; +import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js'; + +export const MAX_RPG_WORLD_PROFILE_ENTRIES = 12; +export const MAX_RPG_WORLD_GALLERY_ENTRIES = 36; + +export type RpgWorldProfileRow = QueryResultRow & { + ownerUserId: string; + profileId: string; + payload: CustomWorldProfileRecord; + visibility: CustomWorldPublicationStatus; + publishedAt: string | null; + updatedAt: string; + authorDisplayName: string; + worldName: string; + subtitle: string; + summaryText: string; + coverImageSrc: string | null; + themeMode: CustomWorldLibraryEntry['themeMode']; + playableNpcCount: number; + landmarkCount: number; +}; + +export type RpgAgentSessionRow = QueryResultRow & { + payload: CustomWorldSessionRecord; + createdAt: string; + updatedAt: string; +}; + +export type RpgWorldGalleryRow = QueryResultRow & { + ownerUserId: string; + profileId: string; + visibility: CustomWorldPublicationStatus; + publishedAt: string | null; + updatedAt: string; + authorDisplayName: string; + worldName: string; + subtitle: string; + summaryText: string; + coverImageSrc: string | null; + themeMode: CustomWorldGalleryCard['themeMode']; + playableNpcCount: number; + landmarkCount: number; +}; + +/** + * 落库前统一补齐 profileId,避免不同入口写入时出现同一世界两个 id 口径。 + */ +export function normalizeStoredRpgWorldProfile( + profileId: string, + profile: Record, +): CustomWorldProfileRecord { + return { + ...profile, + id: profileId, + }; +} + +export function toRpgWorldLibraryEntry( + row: RpgWorldProfileRow, +): CustomWorldLibraryEntry { + const fallbackMetadata = extractCustomWorldLibraryMetadata(row.payload); + + return { + ownerUserId: row.ownerUserId, + profileId: row.profileId, + profile: row.payload, + visibility: row.visibility, + publishedAt: row.publishedAt, + updatedAt: row.updatedAt, + authorDisplayName: row.authorDisplayName || '玩家', + worldName: row.worldName || fallbackMetadata.worldName, + subtitle: row.subtitle || fallbackMetadata.subtitle, + summaryText: row.summaryText || fallbackMetadata.summaryText, + coverImageSrc: row.coverImageSrc || fallbackMetadata.coverImageSrc, + themeMode: row.themeMode || fallbackMetadata.themeMode, + playableNpcCount: + row.playableNpcCount > 0 + ? row.playableNpcCount + : fallbackMetadata.playableNpcCount, + landmarkCount: + row.landmarkCount > 0 + ? row.landmarkCount + : fallbackMetadata.landmarkCount, + }; +} + +export function toRpgWorldGalleryCard( + row: RpgWorldGalleryRow, +): CustomWorldGalleryCard { + return { + ownerUserId: row.ownerUserId, + profileId: row.profileId, + visibility: row.visibility, + publishedAt: row.publishedAt, + updatedAt: row.updatedAt, + authorDisplayName: row.authorDisplayName || '玩家', + worldName: row.worldName || '未命名世界', + subtitle: row.subtitle || '', + summaryText: row.summaryText || '', + coverImageSrc: row.coverImageSrc || null, + themeMode: row.themeMode || 'mythic', + playableNpcCount: row.playableNpcCount, + landmarkCount: row.landmarkCount, + }; +} diff --git a/server-node/src/repositories/runtimeRepository.ts b/server-node/src/repositories/runtimeRepository.ts index ade6f6c8..aed72c0e 100644 --- a/server-node/src/repositories/runtimeRepository.ts +++ b/server-node/src/repositories/runtimeRepository.ts @@ -25,9 +25,9 @@ import { } from '../../../packages/shared/src/contracts/runtime.js'; import type { AppDatabase } from '../db.js'; import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js'; - -const MAX_CUSTOM_WORLD_PROFILES = 12; -const MAX_PUBLIC_CUSTOM_WORLD_PROFILES = 36; +import { RpgAgentSessionRepository } from './RpgAgentSessionRepository.js'; +import { RpgWorldProfileRepository } from './RpgWorldProfileRepository.js'; +import { normalizeStoredRpgWorldProfile } from './rpgWorldRepositoryShared.js'; export type SavedSnapshot = SavedGameSnapshot; @@ -44,45 +44,6 @@ type SettingsRow = QueryResultRow & { platformTheme: RuntimeSettings['platformTheme']; }; -type CustomWorldEntryRow = QueryResultRow & { - ownerUserId: string; - profileId: string; - payload: CustomWorldProfileRecord; - visibility: CustomWorldPublicationStatus; - publishedAt: string | null; - updatedAt: string; - authorDisplayName: string; - worldName: string; - subtitle: string; - summaryText: string; - coverImageSrc: string | null; - themeMode: CustomWorldLibraryEntry['themeMode']; - playableNpcCount: number; - landmarkCount: number; -}; - -type SessionRow = QueryResultRow & { - payload: CustomWorldSessionRecord; - createdAt: string; - updatedAt: string; -}; - -type CustomWorldCardRow = QueryResultRow & { - ownerUserId: string; - profileId: string; - visibility: CustomWorldPublicationStatus; - publishedAt: string | null; - updatedAt: string; - authorDisplayName: string; - worldName: string; - subtitle: string; - summaryText: string; - coverImageSrc: string | null; - themeMode: CustomWorldGalleryCard['themeMode']; - playableNpcCount: number; - landmarkCount: number; -}; - type PlatformBrowseHistoryRow = QueryResultRow & { ownerUserId: string; profileId: string; @@ -227,65 +188,6 @@ export type RuntimeRepositoryPort = { ): Promise | null>; }; -function normalizeStoredProfile( - profileId: string, - profile: Record, -): CustomWorldProfileRecord { - return { - ...profile, - id: profileId, - }; -} - -function toCustomWorldLibraryEntry( - row: CustomWorldEntryRow, -): CustomWorldLibraryEntry { - const fallbackMetadata = extractCustomWorldLibraryMetadata(row.payload); - - return { - ownerUserId: row.ownerUserId, - profileId: row.profileId, - profile: row.payload, - visibility: row.visibility, - publishedAt: row.publishedAt, - updatedAt: row.updatedAt, - authorDisplayName: row.authorDisplayName || '玩家', - worldName: row.worldName || fallbackMetadata.worldName, - subtitle: row.subtitle || fallbackMetadata.subtitle, - summaryText: row.summaryText || fallbackMetadata.summaryText, - coverImageSrc: row.coverImageSrc || fallbackMetadata.coverImageSrc, - themeMode: row.themeMode || fallbackMetadata.themeMode, - playableNpcCount: - row.playableNpcCount > 0 - ? row.playableNpcCount - : fallbackMetadata.playableNpcCount, - landmarkCount: - row.landmarkCount > 0 - ? row.landmarkCount - : fallbackMetadata.landmarkCount, - }; -} - -function toCustomWorldGalleryCard( - row: CustomWorldCardRow, -): CustomWorldGalleryCard { - return { - ownerUserId: row.ownerUserId, - profileId: row.profileId, - visibility: row.visibility, - publishedAt: row.publishedAt, - updatedAt: row.updatedAt, - authorDisplayName: row.authorDisplayName || '玩家', - worldName: row.worldName || '未命名世界', - subtitle: row.subtitle || '', - summaryText: row.summaryText || '', - coverImageSrc: row.coverImageSrc || null, - themeMode: row.themeMode || 'mythic', - playableNpcCount: row.playableNpcCount, - landmarkCount: row.landmarkCount, - }; -} - function toPlatformBrowseHistoryEntry( row: PlatformBrowseHistoryRow, ): PlatformBrowseHistoryEntry { @@ -678,7 +580,7 @@ function resolveProfileSaveArchiveMeta( if (customWorldProfile) { const profileId = readString(customWorldProfile.id) || 'custom-world'; const metadata = extractCustomWorldLibraryMetadata( - normalizeStoredProfile(profileId, customWorldProfile), + normalizeStoredRpgWorldProfile(profileId, customWorldProfile), ); return { @@ -717,33 +619,12 @@ function resolveProfileSaveArchiveMeta( } export class RuntimeRepository implements RuntimeRepositoryPort { - constructor(private readonly db: AppDatabase) {} + private readonly rpgAgentSessionRepository: RpgAgentSessionRepository; + private readonly rpgWorldProfileRepository: RpgWorldProfileRepository; - private async findCustomWorldProfileEntry(userId: string, profileId: string) { - const result = await this.db.query( - `SELECT user_id AS "ownerUserId", - profile_id AS "profileId", - payload_json AS payload, - visibility, - published_at AS "publishedAt", - updated_at AS "updatedAt", - author_display_name AS "authorDisplayName", - world_name AS "worldName", - subtitle, - summary_text AS "summaryText", - cover_image_src AS "coverImageSrc", - theme_mode AS "themeMode", - playable_npc_count AS "playableNpcCount", - landmark_count AS "landmarkCount" - FROM custom_world_profiles - WHERE user_id = $1 - AND profile_id = $2 - AND deleted_at IS NULL`, - [userId, profileId], - ); - - const row = result.rows[0]; - return row ? toCustomWorldLibraryEntry(row) : null; + constructor(private readonly db: AppDatabase) { + this.rpgAgentSessionRepository = new RpgAgentSessionRepository(db); + this.rpgWorldProfileRepository = new RpgWorldProfileRepository(db); } private async getProfileDashboardState(userId: string) { @@ -1043,52 +924,13 @@ export class RuntimeRepository implements RuntimeRepositoryPort { return; } - const payload = normalizeStoredProfile(profileId, customWorldProfile); - const metadata = extractCustomWorldLibraryMetadata(payload); const syncedAt = snapshot.savedAt || new Date().toISOString(); - await this.db.query( - `INSERT INTO custom_world_profiles ( - user_id, - profile_id, - payload_json, - updated_at, - author_display_name, - world_name, - subtitle, - summary_text, - cover_image_src, - theme_mode, - playable_npc_count, - landmark_count, - deleted_at - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULL) - ON CONFLICT (user_id, profile_id) DO UPDATE SET - payload_json = EXCLUDED.payload_json, - updated_at = EXCLUDED.updated_at, - deleted_at = NULL, - world_name = EXCLUDED.world_name, - subtitle = EXCLUDED.subtitle, - summary_text = EXCLUDED.summary_text, - cover_image_src = EXCLUDED.cover_image_src, - theme_mode = EXCLUDED.theme_mode, - playable_npc_count = EXCLUDED.playable_npc_count, - landmark_count = EXCLUDED.landmark_count`, - [ - userId, - profileId, - payload, - syncedAt, - '玩家', - metadata.worldName, - metadata.subtitle, - metadata.summaryText, - metadata.coverImageSrc, - metadata.themeMode, - metadata.playableNpcCount, - metadata.landmarkCount, - ], + await this.rpgWorldProfileRepository.syncProfileFromSnapshot( + userId, + profileId, + customWorldProfile, + syncedAt, ); } @@ -1394,29 +1236,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort { } async listCustomWorldProfiles(userId: string) { - const result = await this.db.query( - `SELECT user_id AS "ownerUserId", - profile_id AS "profileId", - payload_json AS payload, - visibility, - published_at AS "publishedAt", - updated_at AS "updatedAt", - author_display_name AS "authorDisplayName", - world_name AS "worldName", - subtitle, - summary_text AS "summaryText", - cover_image_src AS "coverImageSrc", - theme_mode AS "themeMode", - playable_npc_count AS "playableNpcCount", - landmark_count AS "landmarkCount" - FROM custom_world_profiles - WHERE user_id = $1 - AND deleted_at IS NULL - ORDER BY updated_at DESC - LIMIT $2`, - [userId, MAX_CUSTOM_WORLD_PROFILES], - ); - return result.rows.map((row) => toCustomWorldLibraryEntry(row)); + return this.rpgWorldProfileRepository.listOwnProfiles(userId); } async upsertCustomWorldProfile( @@ -1425,120 +1245,27 @@ export class RuntimeRepository implements RuntimeRepositoryPort { profile: Record, authorDisplayName: string, ) { - const payload = normalizeStoredProfile(profileId, profile); - const metadata = extractCustomWorldLibraryMetadata(payload); - const now = new Date().toISOString(); - - await this.db.query( - `INSERT INTO custom_world_profiles ( - user_id, - profile_id, - payload_json, - updated_at, - author_display_name, - world_name, - subtitle, - summary_text, - cover_image_src, - theme_mode, - playable_npc_count, - landmark_count - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - ON CONFLICT (user_id, profile_id) DO UPDATE SET - payload_json = EXCLUDED.payload_json, - updated_at = EXCLUDED.updated_at, - deleted_at = NULL, - author_display_name = EXCLUDED.author_display_name, - world_name = EXCLUDED.world_name, - subtitle = EXCLUDED.subtitle, - summary_text = EXCLUDED.summary_text, - cover_image_src = EXCLUDED.cover_image_src, - theme_mode = EXCLUDED.theme_mode, - playable_npc_count = EXCLUDED.playable_npc_count, - landmark_count = EXCLUDED.landmark_count`, - [ - userId, - profileId, - payload, - now, - authorDisplayName || '玩家', - metadata.worldName, - metadata.subtitle, - metadata.summaryText, - metadata.coverImageSrc, - metadata.themeMode, - metadata.playableNpcCount, - metadata.landmarkCount, - ], + return this.rpgWorldProfileRepository.upsertOwnProfile( + userId, + profileId, + profile, + authorDisplayName, ); - - const entry = await this.findCustomWorldProfileEntry(userId, profileId); - if (!entry) { - throw new Error('failed to resolve custom world after upsert'); - } - - return { - entry, - entries: await this.listCustomWorldProfiles(userId), - }; } async deleteCustomWorldProfile(userId: string, profileId: string) { - const deletedAt = new Date().toISOString(); - await this.db.query( - `UPDATE custom_world_profiles - SET deleted_at = $1, - updated_at = $1, - visibility = 'draft', - published_at = NULL - WHERE user_id = $2 - AND profile_id = $3 - AND deleted_at IS NULL`, - [deletedAt, userId, profileId], + return this.rpgWorldProfileRepository.softDeleteOwnProfile( + userId, + profileId, ); - - return this.listCustomWorldProfiles(userId); } async listCustomWorldSessions(userId: string) { - const result = await this.db.query( - `SELECT payload_json AS payload, - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM custom_world_sessions - WHERE user_id = $1 - ORDER BY updated_at DESC`, - [userId], - ); - - return result.rows.map((row) => ({ - ...row.payload, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - })); + return this.rpgAgentSessionRepository.listSessions(userId); } async getCustomWorldSession(userId: string, sessionId: string) { - const result = await this.db.query( - `SELECT payload_json AS payload, - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM custom_world_sessions - WHERE user_id = $1 AND session_id = $2`, - [userId, sessionId], - ); - const row = result.rows[0]; - - if (!row) { - return null; - } - - return { - ...row.payload, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; + return this.rpgAgentSessionRepository.getSession(userId, sessionId); } async upsertCustomWorldSession( @@ -1546,30 +1273,11 @@ export class RuntimeRepository implements RuntimeRepositoryPort { sessionId: string, session: CustomWorldSessionRecord, ) { - const payload = { - ...session, + return this.rpgAgentSessionRepository.upsertSession( + userId, sessionId, - } satisfies CustomWorldSessionRecord; - - await this.db.query( - `INSERT INTO custom_world_sessions ( - user_id, - session_id, - payload_json, - created_at, - updated_at - ) VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_id, session_id) DO UPDATE SET - payload_json = EXCLUDED.payload_json, - updated_at = EXCLUDED.updated_at`, - [userId, sessionId, payload, session.createdAt, session.updatedAt], + session, ); - - return { - ...payload, - createdAt: session.createdAt, - updatedAt: session.updatedAt, - }; } async publishCustomWorldProfile( @@ -1577,57 +1285,11 @@ export class RuntimeRepository implements RuntimeRepositoryPort { profileId: string, authorDisplayName: string, ) { - const existingEntry = await this.findCustomWorldProfileEntry( + return this.rpgWorldProfileRepository.publishOwnProfile( userId, profileId, + authorDisplayName, ); - if (!existingEntry) { - return null; - } - - const payload = normalizeStoredProfile(profileId, existingEntry.profile); - const metadata = extractCustomWorldLibraryMetadata(payload); - const now = new Date().toISOString(); - - await this.db.query( - `UPDATE custom_world_profiles - SET visibility = 'published', - published_at = $1, - updated_at = $1, - author_display_name = $2, - world_name = $3, - subtitle = $4, - summary_text = $5, - cover_image_src = $6, - theme_mode = $7, - playable_npc_count = $8, - landmark_count = $9 - WHERE user_id = $10 - AND profile_id = $11`, - [ - now, - authorDisplayName || '玩家', - metadata.worldName, - metadata.subtitle, - metadata.summaryText, - metadata.coverImageSrc, - metadata.themeMode, - metadata.playableNpcCount, - metadata.landmarkCount, - userId, - profileId, - ], - ); - - const entry = await this.findCustomWorldProfileEntry(userId, profileId); - if (!entry) { - throw new Error('failed to resolve custom world after publish'); - } - - return { - entry, - entries: await this.listCustomWorldProfiles(userId), - }; } async unpublishCustomWorldProfile( @@ -1635,113 +1297,24 @@ export class RuntimeRepository implements RuntimeRepositoryPort { profileId: string, authorDisplayName: string, ) { - const existingEntry = await this.findCustomWorldProfileEntry( + return this.rpgWorldProfileRepository.unpublishOwnProfile( userId, profileId, + authorDisplayName, ); - if (!existingEntry) { - return null; - } - - const payload = normalizeStoredProfile(profileId, existingEntry.profile); - const metadata = extractCustomWorldLibraryMetadata(payload); - const now = new Date().toISOString(); - - await this.db.query( - `UPDATE custom_world_profiles - SET visibility = 'draft', - published_at = NULL, - updated_at = $1, - author_display_name = $2, - world_name = $3, - subtitle = $4, - summary_text = $5, - cover_image_src = $6, - theme_mode = $7, - playable_npc_count = $8, - landmark_count = $9 - WHERE user_id = $10 - AND profile_id = $11`, - [ - now, - authorDisplayName || '玩家', - metadata.worldName, - metadata.subtitle, - metadata.summaryText, - metadata.coverImageSrc, - metadata.themeMode, - metadata.playableNpcCount, - metadata.landmarkCount, - userId, - profileId, - ], - ); - - const entry = await this.findCustomWorldProfileEntry(userId, profileId); - if (!entry) { - throw new Error('failed to resolve custom world after unpublish'); - } - - return { - entry, - entries: await this.listCustomWorldProfiles(userId), - }; } async listPublishedCustomWorldGallery() { - const result = await this.db.query( - `SELECT user_id AS "ownerUserId", - profile_id AS "profileId", - visibility, - published_at AS "publishedAt", - updated_at AS "updatedAt", - author_display_name AS "authorDisplayName", - world_name AS "worldName", - subtitle, - summary_text AS "summaryText", - cover_image_src AS "coverImageSrc", - theme_mode AS "themeMode", - playable_npc_count AS "playableNpcCount", - landmark_count AS "landmarkCount" - FROM custom_world_profiles - WHERE visibility = 'published' - AND deleted_at IS NULL - ORDER BY published_at DESC, updated_at DESC - LIMIT $1`, - [MAX_PUBLIC_CUSTOM_WORLD_PROFILES], - ); - - return result.rows.map((row) => toCustomWorldGalleryCard(row)); + return this.rpgWorldProfileRepository.listPublishedGallery(); } async getPublishedCustomWorldGalleryDetail( ownerUserId: string, profileId: string, ) { - const result = await this.db.query( - `SELECT user_id AS "ownerUserId", - profile_id AS "profileId", - payload_json AS payload, - visibility, - published_at AS "publishedAt", - updated_at AS "updatedAt", - author_display_name AS "authorDisplayName", - world_name AS "worldName", - subtitle, - summary_text AS "summaryText", - cover_image_src AS "coverImageSrc", - theme_mode AS "themeMode", - playable_npc_count AS "playableNpcCount", - landmark_count AS "landmarkCount" - FROM custom_world_profiles - WHERE user_id = $1 - AND profile_id = $2 - AND visibility = 'published' - AND deleted_at IS NULL`, - [ownerUserId, profileId], + return this.rpgWorldProfileRepository.getPublishedGalleryDetail( + ownerUserId, + profileId, ); - - const row = result.rows[0]; - return row ? toCustomWorldLibraryEntry(row) : null; } } diff --git a/server-node/src/routes/customWorldAgent.ts b/server-node/src/routes/customWorldAgent.ts index aa353e61..7ae5c472 100644 --- a/server-node/src/routes/customWorldAgent.ts +++ b/server-node/src/routes/customWorldAgent.ts @@ -67,9 +67,29 @@ const actionSchema = z.discriminatedUnion('action', [ generatedAnimationSetId: z.string().trim().nullable().optional(), animationMap: z.record(z.string(), z.unknown()).nullable().optional(), }), + z.object({ + action: z.literal('generate_scene_assets'), + sceneIds: z.array(z.string().trim().min(1)).min(1), + }), + z.object({ + action: z.literal('sync_scene_assets'), + sceneId: z.string().trim().min(1), + sceneKind: z.enum(['camp', 'landmark']), + imageSrc: z.string().trim().min(1), + generatedSceneAssetId: z.string().trim().min(1), + generatedScenePrompt: z.string().trim().nullable().optional(), + generatedSceneModel: z.string().trim().nullable().optional(), + }), + z.object({ + action: z.literal('expand_long_tail'), + }), z.object({ action: z.literal('publish_world'), }), + z.object({ + action: z.literal('revert_checkpoint'), + checkpointId: z.string().trim().min(1), + }), ]); function readParam(param: string | string[] | undefined) { diff --git a/server-node/src/routes/rpg-entry/index.ts b/server-node/src/routes/rpg-entry/index.ts new file mode 100644 index 00000000..f5e9f05d --- /dev/null +++ b/server-node/src/routes/rpg-entry/index.ts @@ -0,0 +1,11 @@ +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'; diff --git a/server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts b/server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts new file mode 100644 index 00000000..52e88c76 --- /dev/null +++ b/server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts @@ -0,0 +1,151 @@ +import { Router } from 'express'; +import { z } from 'zod'; + +import type { + ProfileSaveArchiveResumeResponse, + SavedGameSnapshotInput, +} from '../../../../packages/shared/src/contracts/runtime.js'; +import type { AppContext } from '../../context.js'; +import { badRequest, notFound } from '../../errors.js'; +import { asyncHandler, sendApiResponse } from '../../http.js'; +import { requireJwtAuth } from '../../middleware/auth.js'; +import { routeMeta } from '../../middleware/routeMeta.js'; +import { + hydrateSavedSnapshot, + normalizeSavedSnapshotPayload, +} from '../../modules/runtime/runtimeSnapshotHydration.js'; + +const saveSnapshotSchema = z.object({ + gameState: z.unknown(), + bottomTab: z.string().trim().min(1), + currentStory: z.unknown().nullable().optional().default(null), + savedAt: z.string().trim().optional().default(''), +}); + +export const RPG_ENTRY_SAVE_ROUTE_BASE_PATH = '/api/runtime/save'; +export const RPG_ENTRY_SAVE_ARCHIVE_ROUTE_BASE_PATH = + '/api/runtime/profile/save-archives'; +export const RPG_ENTRY_SAVE_ARCHIVE_LEGACY_ROUTE_BASE_PATH = + '/api/profile/save-archives'; + +function readParam(param: string | string[] | undefined) { + return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || ''; +} + +function routeCompatPaths(path: string) { + return [path, path.replace('runtime/', '')] as const; +} + +export function createRpgEntrySaveRoutes(context: AppContext) { + const router = Router(); + const requireAuth = requireJwtAuth(context.config, context.userRepository); + + router.get( + '/runtime/save/snapshot', + requireAuth, + routeMeta({ operation: 'runtime.snapshot.get' }), + asyncHandler(async (request, response) => { + sendApiResponse( + response, + hydrateSavedSnapshot( + await context.rpgRuntimeSnapshotRepository.getSnapshot(request.userId!), + ), + ); + }), + ); + + router.put( + '/runtime/save/snapshot', + requireAuth, + routeMeta({ operation: 'runtime.snapshot.put' }), + asyncHandler(async (request, response) => { + const payload = saveSnapshotSchema.parse( + request.body, + ) as SavedGameSnapshotInput; + const normalizedSnapshot = normalizeSavedSnapshotPayload({ + savedAt: payload.savedAt || new Date().toISOString(), + gameState: payload.gameState, + bottomTab: payload.bottomTab, + currentStory: payload.currentStory ?? null, + }); + sendApiResponse( + response, + hydrateSavedSnapshot( + await context.rpgRuntimeSnapshotRepository.putSnapshot( + request.userId!, + normalizedSnapshot, + ), + ), + ); + }), + ); + + router.delete( + '/runtime/save/snapshot', + requireAuth, + routeMeta({ operation: 'runtime.snapshot.delete' }), + asyncHandler(async (request, response) => { + await context.rpgRuntimeSnapshotRepository.deleteSnapshot(request.userId!); + sendApiResponse(response, { ok: true }); + }), + ); + + [ + '/runtime/profile/save-archives/:worldKey', + '/profile/save-archives/:worldKey', + ].forEach((path, index) => { + router.post( + path, + requireAuth, + routeMeta({ + operation: + index === 0 + ? 'profile.saveArchives.resume' + : 'profile.saveArchives.resume.compat', + }), + asyncHandler(async (request, response) => { + const worldKey = readParam(request.params.worldKey); + if (!worldKey) { + throw badRequest('worldKey 不能为空'); + } + + const resumedArchive = + await context.rpgSaveArchiveRepository.resumeProfileSaveArchive( + request.userId!, + worldKey, + ); + + if (!resumedArchive) { + throw notFound('指定存档不存在'); + } + + sendApiResponse(response, { + entry: resumedArchive.entry, + snapshot: hydrateSavedSnapshot(resumedArchive.snapshot)!, + }); + }), + ); + }); + + routeCompatPaths('/api/runtime/profile/save-archives').forEach((path, index) => { + router.get( + path.replace('/api/', '/'), + requireAuth, + routeMeta({ + operation: + index === 0 + ? 'profile.saveArchives.list' + : 'profile.saveArchives.list.compat', + }), + asyncHandler(async (request, response) => { + sendApiResponse(response, { + entries: await context.rpgSaveArchiveRepository.listProfileSaveArchives( + request.userId!, + ), + }); + }), + ); + }); + + return router; +} diff --git a/server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts b/server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts new file mode 100644 index 00000000..5be8d8f0 --- /dev/null +++ b/server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts @@ -0,0 +1,338 @@ +import { Router } from 'express'; +import { z } from 'zod'; + +import type { ListCustomWorldWorksResponse } from '../../../../packages/shared/src/contracts/customWorldAgent.js'; +import type { + CustomWorldGalleryDetailResponse, + CustomWorldGalleryResponse, + CustomWorldLibraryMutationResponse, + CustomWorldLibraryResponse, +} from '../../../../packages/shared/src/contracts/runtime.js'; +import type { AppContext } from '../../context.js'; +import { badRequest, conflict, notFound } from '../../errors.js'; +import { asyncHandler, jsonClone, sendApiResponse } from '../../http.js'; +import { requireJwtAuth } from '../../middleware/auth.js'; +import { routeMeta } from '../../middleware/routeMeta.js'; +import { CustomWorldAgentPublishingService } from '../../services/customWorldAgentPublishingService.js'; + +const jsonObjectSchema = z.record(z.string(), z.unknown()); + +const customWorldProfileSchema = z.object({ + profile: jsonObjectSchema, +}); + +export const RPG_WORLD_LIBRARY_ROUTE_BASE_PATH = + '/api/runtime/custom-world-library'; +export const RPG_WORLD_GALLERY_ROUTE_BASE_PATH = + '/api/runtime/custom-world-gallery'; +export const RPG_WORLD_WORKS_ROUTE_BASE_PATH = + '/api/runtime/custom-world/works'; +const AGENT_DRAFT_PROFILE_ID_PREFIX = 'agent-draft-'; + +function readParam(param: string | string[] | undefined) { + return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || ''; +} + +function toText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function resolveAgentSessionIdFromProfileId(profileId: string) { + if (!profileId.startsWith(AGENT_DRAFT_PROFILE_ID_PREFIX)) { + return null; + } + + const sessionId = profileId.slice(AGENT_DRAFT_PROFILE_ID_PREFIX.length).trim(); + return sessionId || null; +} + +function resolvePublishedWorldName(profile: unknown) { + const profileRecord = + profile && typeof profile === 'object' && !Array.isArray(profile) + ? (profile as Record) + : null; + + return toText(profileRecord?.name) || '当前世界'; +} + +async function syncAgentSessionPublishedState(params: { + context: AppContext; + userId: string; + sessionId: string; + worldName: string; + qualityFindings: Array<{ + id: string; + severity: 'info' | 'warning' | 'blocker'; + code: string; + targetId?: string | null; + message: string; + }>; +}) { + const publishedQualityFindings = params.qualityFindings.filter( + (entry) => entry.severity !== 'blocker', + ); + const publishedState = { + stage: 'published' as const, + qualityFindings: publishedQualityFindings, + }; + + await params.context.customWorldAgentSessions.replaceDerivedState( + params.userId, + params.sessionId, + publishedState, + ); + await params.context.customWorldAgentSessions.appendCheckpoint( + params.userId, + params.sessionId, + { + label: `发布世界 ${params.worldName}`, + snapshot: publishedState, + }, + ); + await params.context.customWorldAgentSessions.appendMessage( + params.userId, + params.sessionId, + { + id: `message-${Date.now().toString(36)}-library-publish`, + role: 'assistant', + kind: 'action_result', + text: + publishedQualityFindings.length > 0 + ? `世界「${params.worldName}」已发布,并保留 ${publishedQualityFindings.length} 条 warning 供后续继续优化。` + : `世界「${params.worldName}」已正式发布,可以进入作品库与世界入口。`, + createdAt: new Date().toISOString(), + relatedOperationId: null, + }, + ); +} + +async function resolveAuthDisplayName(context: AppContext, userId: string) { + const user = await context.userRepository.findById(userId); + if (!user) { + throw notFound('user not found'); + } + + return user.displayName?.trim() || '玩家'; +} + +export function createRpgWorldLibraryRoutes(context: AppContext) { + const router = Router(); + const requireAuth = requireJwtAuth(context.config, context.userRepository); + const publishingService = new CustomWorldAgentPublishingService( + context.rpgWorldProfileRepository, + ); + + router.get( + '/runtime/custom-world-gallery', + routeMeta({ operation: 'runtime.customWorldGallery.list' }), + asyncHandler(async (_request, response) => { + sendApiResponse(response, { + entries: + await context.rpgWorldLibraryRepository.listPublishedCustomWorldGallery(), + } satisfies CustomWorldGalleryResponse); + }), + ); + + router.get( + '/runtime/custom-world-gallery/:ownerUserId/:profileId', + routeMeta({ operation: 'runtime.customWorldGallery.detail' }), + asyncHandler(async (request, response) => { + const ownerUserId = readParam(request.params.ownerUserId); + const profileId = readParam(request.params.profileId); + if (!ownerUserId || !profileId) { + throw badRequest('ownerUserId and profileId are required'); + } + + const entry = + await context.rpgWorldLibraryRepository.getPublishedCustomWorldGalleryDetail( + ownerUserId, + profileId, + ); + if (!entry) { + throw notFound('public custom world not found'); + } + + sendApiResponse(response, { + entry, + } satisfies CustomWorldGalleryDetailResponse); + }), + ); + + router.get( + '/runtime/custom-world/works', + requireAuth, + routeMeta({ operation: 'runtime.customWorldWorks.list' }), + asyncHandler(async (request, response) => { + sendApiResponse(response, { + items: await context.rpgWorldWorkSummaryService.list(request.userId!), + }); + }), + ); + + router.get( + '/runtime/custom-world-library', + requireAuth, + routeMeta({ operation: 'runtime.customWorldLibrary.list' }), + asyncHandler(async (request, response) => { + sendApiResponse(response, { + entries: await context.rpgWorldLibraryRepository.listCustomWorldProfiles( + request.userId!, + ), + } satisfies CustomWorldLibraryResponse); + }), + ); + + router.put( + '/runtime/custom-world-library/:profileId', + requireAuth, + routeMeta({ operation: 'runtime.customWorldLibrary.upsert' }), + asyncHandler(async (request, response) => { + const profileId = readParam(request.params.profileId); + if (!profileId) { + throw badRequest('profileId is required'); + } + + const payload = customWorldProfileSchema.parse(request.body); + const authorDisplayName = await resolveAuthDisplayName( + context, + request.userId!, + ); + sendApiResponse( + response, + await context.rpgWorldLibraryRepository.upsertCustomWorldProfile( + request.userId!, + profileId, + jsonClone(payload.profile), + authorDisplayName, + ), + ); + }), + ); + + router.delete( + '/runtime/custom-world-library/:profileId', + requireAuth, + routeMeta({ operation: 'runtime.customWorldLibrary.delete' }), + asyncHandler(async (request, response) => { + const profileId = readParam(request.params.profileId); + if (!profileId) { + throw badRequest('profileId is required'); + } + + sendApiResponse(response, { + entries: await context.rpgWorldLibraryRepository.deleteCustomWorldProfile( + request.userId!, + profileId, + ), + } satisfies CustomWorldLibraryResponse); + }), + ); + + router.post( + '/runtime/custom-world-library/:profileId/publish', + requireAuth, + routeMeta({ operation: 'runtime.customWorldLibrary.publish' }), + asyncHandler(async (request, response) => { + const profileId = readParam(request.params.profileId); + if (!profileId) { + throw badRequest('profileId is required'); + } + + const authorDisplayName = await resolveAuthDisplayName( + context, + request.userId!, + ); + const agentSessionId = resolveAgentSessionIdFromProfileId(profileId); + if (agentSessionId) { + const agentSession = await context.customWorldAgentSessions.get( + request.userId!, + agentSessionId, + ); + + if (agentSession) { + try { + publishingService.buildPublishReadiness({ + sessionId: agentSessionId, + draftProfile: agentSession.draftProfile, + qualityFindings: agentSession.qualityFindings, + }); + } catch (error) { + throw conflict( + error instanceof Error + ? error.message + : '当前世界还没有通过发布校验。', + ); + } + + const publishResult = await publishingService.publishSessionDraft({ + userId: request.userId!, + authorDisplayName, + sessionId: agentSessionId, + draftProfile: + (agentSession.draftProfile ?? {}) as Record, + qualityFindings: agentSession.qualityFindings, + }); + await syncAgentSessionPublishedState({ + context, + userId: request.userId!, + sessionId: agentSessionId, + worldName: resolvePublishedWorldName(publishResult.publishedProfile), + qualityFindings: agentSession.qualityFindings, + }); + sendApiResponse( + response, + publishResult.mutation satisfies CustomWorldLibraryMutationResponse, + ); + return; + } + } + + const mutation = await context.rpgWorldLibraryRepository.publishCustomWorldProfile( + request.userId!, + profileId, + authorDisplayName, + ); + if (!mutation) { + throw notFound('custom world not found'); + } + + sendApiResponse( + response, + mutation satisfies CustomWorldLibraryMutationResponse, + ); + }), + ); + + router.post( + '/runtime/custom-world-library/:profileId/unpublish', + requireAuth, + routeMeta({ operation: 'runtime.customWorldLibrary.unpublish' }), + asyncHandler(async (request, response) => { + const profileId = readParam(request.params.profileId); + if (!profileId) { + throw badRequest('profileId is required'); + } + + const authorDisplayName = await resolveAuthDisplayName( + context, + request.userId!, + ); + const mutation = + await context.rpgWorldLibraryRepository.unpublishCustomWorldProfile( + request.userId!, + profileId, + authorDisplayName, + ); + if (!mutation) { + throw notFound('custom world not found'); + } + + sendApiResponse( + response, + mutation satisfies CustomWorldLibraryMutationResponse, + ); + }), + ); + + return router; +} diff --git a/server-node/src/routes/rpg-profile/index.ts b/server-node/src/routes/rpg-profile/index.ts new file mode 100644 index 00000000..b1d2cc8f --- /dev/null +++ b/server-node/src/routes/rpg-profile/index.ts @@ -0,0 +1,4 @@ +export { + createRpgProfileRoutes, + RPG_PROFILE_ROUTE_BASE_PATH, +} from './rpgProfileRoutes.js'; diff --git a/server-node/src/routes/rpg-profile/rpgProfileRoutes.ts b/server-node/src/routes/rpg-profile/rpgProfileRoutes.ts new file mode 100644 index 00000000..e86076b5 --- /dev/null +++ b/server-node/src/routes/rpg-profile/rpgProfileRoutes.ts @@ -0,0 +1,214 @@ +import { Router } from 'express'; +import { z } from 'zod'; + +import type { + PlatformBrowseHistoryBatchSyncRequest, + PlatformBrowseHistoryResponse, + PlatformBrowseHistoryWriteEntry, + ProfileDashboardSummary, + ProfilePlayStatsResponse, + ProfileWalletLedgerResponse, + RuntimeSettings, +} from '../../../../packages/shared/src/contracts/runtime.js'; +import { PLATFORM_THEMES } from '../../../../packages/shared/src/contracts/runtime.js'; +import type { AppContext } from '../../context.js'; +import { asyncHandler, sendApiResponse } from '../../http.js'; +import { requireJwtAuth } from '../../middleware/auth.js'; +import { routeMeta } from '../../middleware/routeMeta.js'; + +const platformBrowseHistoryEntrySchema = z.object({ + ownerUserId: z.string().trim().min(1), + profileId: z.string().trim().min(1), + worldName: z.string().trim().min(1), + subtitle: z.string().trim().optional().default(''), + summaryText: z.string().trim().optional().default(''), + coverImageSrc: z.string().trim().nullable().optional().default(null), + themeMode: z.string().trim().optional().default('mythic'), + authorDisplayName: z.string().trim().optional().default('玩家'), + visitedAt: z.string().trim().optional().default(''), +}); + +const platformBrowseHistoryBatchSchema = z.object({ + entries: z.array(platformBrowseHistoryEntrySchema).max(100), +}); + +const settingsSchema = z.object({ + musicVolume: z.number().min(0).max(1), + platformTheme: z.enum(PLATFORM_THEMES), +}); + +export const RPG_PROFILE_ROUTE_BASE_PATH = '/api/runtime/profile'; +export const RPG_PROFILE_LEGACY_ROUTE_BASE_PATH = '/api/profile'; + +function routeCompatPaths(path: string) { + return [path, path.replace('runtime/', '')] as const; +} + +export function createRpgProfileRoutes(context: AppContext) { + const router = Router(); + const requireAuth = requireJwtAuth(context.config, context.userRepository); + + routeCompatPaths('/api/runtime/profile/dashboard').forEach((path, index) => { + router.get( + path.replace('/api/', '/'), + requireAuth, + routeMeta({ + operation: + index === 0 + ? 'profile.dashboard.get' + : 'profile.dashboard.get.compat', + }), + asyncHandler(async (request, response) => { + sendApiResponse( + response, + await context.rpgProfileDashboardRepository.getProfileDashboard( + request.userId!, + ), + ); + }), + ); + }); + + routeCompatPaths('/api/runtime/profile/wallet-ledger').forEach((path, index) => { + router.get( + path.replace('/api/', '/'), + requireAuth, + routeMeta({ + operation: + index === 0 + ? 'profile.walletLedger.list' + : 'profile.walletLedger.list.compat', + }), + asyncHandler(async (request, response) => { + sendApiResponse(response, { + entries: + await context.rpgProfileDashboardRepository.listProfileWalletLedger( + request.userId!, + ), + }); + }), + ); + }); + + routeCompatPaths('/api/runtime/profile/play-stats').forEach((path, index) => { + router.get( + path.replace('/api/', '/'), + requireAuth, + routeMeta({ + operation: + index === 0 + ? 'profile.playStats.get' + : 'profile.playStats.get.compat', + }), + asyncHandler(async (request, response) => { + sendApiResponse( + response, + await context.rpgProfileDashboardRepository.getProfilePlayStats( + request.userId!, + ), + ); + }), + ); + }); + + routeCompatPaths('/api/runtime/profile/browse-history').forEach((path, index) => { + router.get( + path.replace('/api/', '/'), + requireAuth, + routeMeta({ + operation: + index === 0 + ? 'profile.browseHistory.list' + : 'profile.browseHistory.list.compat', + }), + asyncHandler(async (request, response) => { + sendApiResponse(response, { + entries: await context.rpgBrowseHistoryRepository.listPlatformBrowseHistory( + request.userId!, + ), + }); + }), + ); + + router.post( + path.replace('/api/', '/'), + requireAuth, + routeMeta({ + operation: + index === 0 + ? 'profile.browseHistory.upsert' + : 'profile.browseHistory.upsert.compat', + }), + asyncHandler(async (request, response) => { + const rawBody = + request.body && typeof request.body === 'object' ? request.body : {}; + const payload = ( + 'entries' in rawBody + ? platformBrowseHistoryBatchSchema.parse(rawBody) + : platformBrowseHistoryEntrySchema.parse(rawBody) + ) as + | PlatformBrowseHistoryBatchSyncRequest + | PlatformBrowseHistoryWriteEntry; + + const entries = 'entries' in payload ? payload.entries : [payload]; + + sendApiResponse(response, { + entries: + await context.rpgBrowseHistoryRepository.upsertPlatformBrowseHistoryEntries( + request.userId!, + entries, + ), + }); + }), + ); + + router.delete( + path.replace('/api/', '/'), + requireAuth, + routeMeta({ + operation: + index === 0 + ? 'profile.browseHistory.clear' + : 'profile.browseHistory.clear.compat', + }), + asyncHandler(async (request, response) => { + await context.rpgBrowseHistoryRepository.clearPlatformBrowseHistory( + request.userId!, + ); + sendApiResponse(response, { + entries: [], + }); + }), + ); + }); + + router.get( + '/api/runtime/settings'.replace('/api/', '/'), + requireAuth, + routeMeta({ operation: 'runtime.settings.get' }), + asyncHandler(async (request, response) => { + sendApiResponse( + response, + await context.rpgProfileDashboardRepository.getSettings(request.userId!), + ); + }), + ); + + router.put( + '/api/runtime/settings'.replace('/api/', '/'), + requireAuth, + routeMeta({ operation: 'runtime.settings.put' }), + asyncHandler(async (request, response) => { + const payload = settingsSchema.parse(request.body) as RuntimeSettings; + sendApiResponse( + response, + await context.rpgProfileDashboardRepository.putSettings( + request.userId!, + payload, + ), + ); + }), + ); + + return router; +} diff --git a/server-node/src/routes/rpg-runtime/index.ts b/server-node/src/routes/rpg-runtime/index.ts new file mode 100644 index 00000000..fe2a5b4f --- /dev/null +++ b/server-node/src/routes/rpg-runtime/index.ts @@ -0,0 +1,8 @@ +export { + createRpgRuntimeAiAssistRoutes, + RPG_RUNTIME_AI_ASSIST_ROUTE_BASE_PATH, +} from './rpgRuntimeAiAssistRoutes.js'; +export { + createRpgRuntimeStoryRoutes, + RPG_RUNTIME_STORY_ROUTE_BASE_PATH, +} from './rpgRuntimeStoryRoutes.js'; diff --git a/server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts b/server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts new file mode 100644 index 00000000..06973c12 --- /dev/null +++ b/server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts @@ -0,0 +1,370 @@ +import { Router } from 'express'; +import { z } from 'zod'; + +import type { + QuestGenerationRequest, + RuntimeItemIntentRequest, +} from '../../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js'; +import type { + CharacterChatReplyRequest, + CharacterChatSuggestionsRequest, + CharacterChatSummaryRequest, + NpcChatDialogueRequest, + NpcChatTurnRequest, + NpcRecruitDialogueRequest, + StoryRequestPayload, +} from '../../../../packages/shared/src/contracts/rpgRuntimeChat.js'; +import type { GenerateCustomWorldProfileInput } from '../../../../packages/shared/src/contracts/runtime.js'; +import type { AppContext } from '../../context.js'; +import { asyncHandler, sendApiResponse } from '../../http.js'; +import { requireJwtAuth } from '../../middleware/auth.js'; +import { routeMeta } from '../../middleware/routeMeta.js'; +import { + generateCharacterChatSuggestionsFromOrchestrator, + generateCharacterChatSummaryFromOrchestrator, + streamCharacterChatReplyFromOrchestrator, + streamNpcChatDialogueFromOrchestrator, + streamNpcChatTurnFromOrchestrator, + streamNpcRecruitDialogueFromOrchestrator, +} from '../../modules/ai/chatOrchestrator.js'; +import { generateCustomWorldProfileFromOrchestrator } from '../../modules/ai/customWorldOrchestrator.js'; +import { + characterChatReplyRequestSchema, + characterChatSuggestionsRequestSchema, + characterChatSummaryRequestSchema, + npcChatDialogueRequestSchema, + npcChatTurnRequestSchema, + npcRecruitDialogueRequestSchema, +} from '../../services/chatService.js'; +import { + customWorldCoverImageSchema, + customWorldCoverUploadSchema, + generateCustomWorldCoverImage, + uploadCustomWorldCoverImage, +} from '../../services/customWorldCoverAssetService.js'; +import { generateCustomWorldEntity } from '../../services/customWorldEntityGenerationService.js'; +import { generateSceneNpcForLandmark } from '../../services/customWorldSceneNpcGenerationService.js'; +import { generateQuestForNpcEncounter } from '../../services/questService.js'; +import { generateRuntimeItemIntents } from '../../services/runtimeItemService.js'; +import { + generateSceneImage, + sceneImageSchema, +} from '../../services/sceneImageService.js'; +import { + generateHighQualityInitialStory, + generateHighQualityNextStory, + parseStoryRequest, +} from '../../services/storyService.js'; + +const jsonObjectSchema = z.record(z.string(), z.unknown()); + +const customWorldProfileGenerationSchema = z.object({ + settingText: z.string().trim().min(1), + creatorIntent: jsonObjectSchema.nullish(), + generationMode: z.enum(['fast', 'full']).optional(), +}); + +const customWorldSceneNpcSchema = z.object({ + profile: jsonObjectSchema, + landmarkId: z.string().trim().min(1), +}); + +const customWorldEntitySchema = z.object({ + profile: jsonObjectSchema, + kind: z.enum(['playable', 'story', 'landmark']), +}); + +const runtimeItemIntentSchema = z.object({ + context: jsonObjectSchema, + plans: z.array(jsonObjectSchema), +}); + +const questGenerationSchema = z.object({ + state: jsonObjectSchema, + encounter: jsonObjectSchema, +}); + +const llmProxySchema = jsonObjectSchema; + +export const RPG_RUNTIME_AI_ASSIST_ROUTE_BASE_PATH = '/api/runtime'; + +export function createRpgRuntimeAiAssistRoutes(context: AppContext) { + const router = Router(); + const requireAuth = requireJwtAuth(context.config, context.userRepository); + const handleCustomWorldEntityGeneration = asyncHandler( + async (request, response) => { + const payload = customWorldEntitySchema.parse(request.body) as { + profile: Record; + kind: 'playable' | 'story' | 'landmark'; + }; + sendApiResponse( + response, + await generateCustomWorldEntity(context.llmClient, payload), + ); + }, + ); + const handleCustomWorldSceneNpcGeneration = asyncHandler( + async (request, response) => { + const payload = customWorldSceneNpcSchema.parse(request.body) as { + profile: Record; + landmarkId: string; + }; + sendApiResponse(response, { + npc: await generateSceneNpcForLandmark(context.llmClient, payload), + }); + }, + ); + + router.post( + '/llm/chat/completions', + requireAuth, + routeMeta({ operation: 'runtime.llm.chatCompletionsProxy' }), + asyncHandler(async (request, response) => { + const body = llmProxySchema.parse(request.body); + await context.llmClient.forwardCompletion(request, body, response); + }), + ); + + router.post( + '/custom-world/cover-image', + requireAuth, + routeMeta({ operation: 'runtime.customWorld.coverImage' }), + asyncHandler(async (request, response) => { + const payload = customWorldCoverImageSchema.parse(request.body); + sendApiResponse(response, await generateCustomWorldCoverImage(context, payload)); + }), + ); + + router.post( + '/custom-world/cover-upload', + requireAuth, + routeMeta({ operation: 'runtime.customWorld.coverUpload' }), + asyncHandler(async (request, response) => { + const payload = customWorldCoverUploadSchema.parse(request.body); + sendApiResponse(response, await uploadCustomWorldCoverImage(context, payload)); + }), + ); + + router.post( + '/custom-world/scene-image', + requireAuth, + routeMeta({ operation: 'runtime.customWorld.sceneImage' }), + asyncHandler(async (request, response) => { + const payload = sceneImageSchema.parse(request.body); + sendApiResponse(response, await generateSceneImage(context, payload)); + }), + ); + + router.post( + '/custom-world/entity', + requireAuth, + routeMeta({ operation: 'runtime.customWorld.entity' }), + handleCustomWorldEntityGeneration, + ); + + router.post( + '/runtime/custom-world/entity', + requireAuth, + routeMeta({ operation: 'runtime.customWorld.entity.compat' }), + handleCustomWorldEntityGeneration, + ); + + router.post( + '/custom-world/scene-npc', + requireAuth, + routeMeta({ operation: 'runtime.customWorld.sceneNpc' }), + handleCustomWorldSceneNpcGeneration, + ); + + router.post( + '/runtime/custom-world/scene-npc', + requireAuth, + routeMeta({ operation: 'runtime.customWorld.sceneNpc.compat' }), + handleCustomWorldSceneNpcGeneration, + ); + + router.post( + '/runtime/custom-world/profile', + requireAuth, + routeMeta({ operation: 'runtime.customWorld.profile' }), + asyncHandler(async (request, response) => { + const payload = customWorldProfileGenerationSchema.parse( + request.body, + ) as GenerateCustomWorldProfileInput; + sendApiResponse( + response, + await generateCustomWorldProfileFromOrchestrator( + context.llmClient, + payload, + ), + ); + }), + ); + + router.post( + '/runtime/story/initial', + requireAuth, + routeMeta({ operation: 'runtime.story.initial' }), + asyncHandler(async (request, response) => { + const payload = parseStoryRequest(request.body) as StoryRequestPayload; + sendApiResponse( + response, + await generateHighQualityInitialStory(context.llmClient, payload), + ); + }), + ); + + router.post( + '/runtime/story/continue', + requireAuth, + routeMeta({ operation: 'runtime.story.continue' }), + asyncHandler(async (request, response) => { + const payload = parseStoryRequest(request.body) as StoryRequestPayload; + sendApiResponse( + response, + await generateHighQualityNextStory(context.llmClient, payload), + ); + }), + ); + + router.post( + '/runtime/chat/character/suggestions', + requireAuth, + routeMeta({ operation: 'runtime.chat.character.suggestions' }), + asyncHandler(async (request, response) => { + const payload = characterChatSuggestionsRequestSchema.parse( + request.body, + ) as CharacterChatSuggestionsRequest; + sendApiResponse(response, { + text: await generateCharacterChatSuggestionsFromOrchestrator( + context.llmClient, + payload, + ), + }); + }), + ); + + router.post( + '/runtime/chat/character/summary', + requireAuth, + routeMeta({ operation: 'runtime.chat.character.summary' }), + asyncHandler(async (request, response) => { + const payload = characterChatSummaryRequestSchema.parse( + request.body, + ) as CharacterChatSummaryRequest; + sendApiResponse(response, { + text: await generateCharacterChatSummaryFromOrchestrator( + context.llmClient, + payload, + ), + }); + }), + ); + + router.post( + '/runtime/chat/character/reply/stream', + requireAuth, + routeMeta({ operation: 'runtime.chat.character.replyStream' }), + asyncHandler(async (request, response) => { + const payload = characterChatReplyRequestSchema.parse( + request.body, + ) as CharacterChatReplyRequest; + await streamCharacterChatReplyFromOrchestrator(context.llmClient, { + request, + response, + payload, + }); + }), + ); + + router.post( + '/runtime/chat/npc/dialogue/stream', + requireAuth, + routeMeta({ operation: 'runtime.chat.npc.dialogueStream' }), + asyncHandler(async (request, response) => { + const payload = npcChatDialogueRequestSchema.parse( + request.body, + ) as NpcChatDialogueRequest; + await streamNpcChatDialogueFromOrchestrator(context.llmClient, { + request, + response, + payload, + }); + }), + ); + + router.post( + '/runtime/chat/npc/turn/stream', + requireAuth, + routeMeta({ operation: 'runtime.chat.npc.turnStream' }), + asyncHandler(async (request, response) => { + const payload = npcChatTurnRequestSchema.parse( + request.body, + ) as NpcChatTurnRequest; + await streamNpcChatTurnFromOrchestrator(context.llmClient, { + request, + response, + payload, + }); + }), + ); + + router.post( + '/runtime/chat/npc/recruit/stream', + requireAuth, + routeMeta({ operation: 'runtime.chat.npc.recruitStream' }), + asyncHandler(async (request, response) => { + const payload = npcRecruitDialogueRequestSchema.parse( + request.body, + ) as NpcRecruitDialogueRequest; + await streamNpcRecruitDialogueFromOrchestrator(context.llmClient, { + request, + response, + payload, + }); + }), + ); + + router.post( + '/runtime/items/runtime-intent', + requireAuth, + routeMeta({ operation: 'runtime.items.intent' }), + asyncHandler(async (request, response) => { + const payload = runtimeItemIntentSchema.parse( + request.body, + ) as RuntimeItemIntentRequest; + sendApiResponse(response, { + intents: await generateRuntimeItemIntents(context.llmClient, payload), + }); + }), + ); + + router.post( + '/runtime/quests/generate', + requireAuth, + routeMeta({ operation: 'runtime.quests.generate' }), + asyncHandler(async (request, response) => { + const payload = questGenerationSchema.parse( + request.body, + ) as QuestGenerationRequest; + sendApiResponse( + response, + await generateQuestForNpcEncounter(context.llmClient, payload), + ); + }), + ); + + router.get( + '/ws/health', + requireAuth, + routeMeta({ operation: 'runtime.ws.health' }), + (_request, response) => { + sendApiResponse(response, { + ok: true, + message: 'websocket routes reserved for future real-time support', + }); + }, + ); + + return router; +} diff --git a/server-node/src/modules/story/storyActionRoutes.test.ts b/server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.test.ts similarity index 91% rename from server-node/src/modules/story/storyActionRoutes.test.ts rename to server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.test.ts index ea59e137..2596303d 100644 --- a/server-node/src/modules/story/storyActionRoutes.test.ts +++ b/server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.test.ts @@ -8,10 +8,10 @@ import test from 'node:test'; import { createApp } from '../../app.ts'; import { buildQuestForEncounter } from '../../bridges/legacyQuestProgressBridge.js'; import type { AppConfig } from '../../config.ts'; +import { applyQuestSignal } from '../../modules/quest/questProgressionService.ts'; import { createAppContext } from '../../server.ts'; import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.ts'; import { httpRequest, type TestRequestInit } from '../../testHttp.ts'; -import { applyQuestSignal } from '../quest/questProgressionService.ts'; function createTestConfig(testName: string): AppConfig { const tempRoot = fs.mkdtempSync( @@ -1886,6 +1886,243 @@ test('runtime story actions resolve equipment_equip and persist updated loadout' }); }); +test('runtime story actions resolve npc_recruit directly on the server', async () => { + await withTestServer('task6-recruit-direct', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'story_task6_recruit', 'secret123'); + + await putSnapshot( + baseUrl, + entry.token, + createTask6GameState({ + currentEncounter: { + kind: 'npc', + id: 'npc_guard_01', + npcName: '守桥人', + npcDescription: '在桥口驻守多年的旧识', + context: '桥口守卫', + characterId: 'bridge-guard', + }, + npcInteractionActive: true, + npcStates: { + npc_guard_01: { + affinity: 64, + chattedCount: 2, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + }, + companions: [], + roster: [], + }), + ); + + const response = await httpRequest( + `${baseUrl}/api/runtime/story/actions/resolve`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: 0, + action: { + type: 'story_choice', + functionId: 'npc_recruit', + payload: { + preludeText: + '守桥人:你既然想清楚了,那我就跟你走这一程。', + }, + }, + }), + }), + ); + const payload = (await response.json()) as { + presentation: { + storyText: string; + }; + snapshot: { + gameState: { + currentEncounter: unknown; + npcInteractionActive: boolean; + companions: Array<{ + npcId: string; + characterId: string; + joinedAtAffinity: number; + maxHp: number; + maxMana: number; + }>; + roster: Array; + npcStates: { + npc_guard_01: { + recruited: boolean; + firstMeaningfulContactResolved: boolean; + }; + }; + }; + }; + viewModel: { + companions: Array<{ + npcId: string; + }>; + }; + }; + + assert.equal(response.status, 200); + assert.match(payload.presentation.storyText, /守桥人/u); + assert.equal(payload.snapshot.gameState.currentEncounter, null); + assert.equal(payload.snapshot.gameState.npcInteractionActive, false); + assert.equal(payload.snapshot.gameState.companions.length, 1); + assert.equal(payload.snapshot.gameState.companions[0]?.npcId, 'npc_guard_01'); + assert.equal( + payload.snapshot.gameState.companions[0]?.joinedAtAffinity, + 64, + ); + assert.ok((payload.snapshot.gameState.companions[0]?.maxHp ?? 0) > 0); + assert.ok((payload.snapshot.gameState.companions[0]?.maxMana ?? 0) > 0); + assert.deepEqual(payload.snapshot.gameState.roster, []); + assert.equal( + payload.snapshot.gameState.npcStates.npc_guard_01.recruited, + true, + ); + assert.equal( + payload.snapshot.gameState.npcStates.npc_guard_01 + .firstMeaningfulContactResolved, + true, + ); + assert.equal(payload.viewModel.companions[0]?.npcId, 'npc_guard_01'); + }); +}); + +test('runtime story actions resolve npc_recruit with full-party replacement on the server', async () => { + await withTestServer('task6-recruit-swap', async ({ baseUrl }) => { + const entry = await authEntry( + baseUrl, + 'story_task6_recruit_swap', + 'secret123', + ); + + await putSnapshot( + baseUrl, + entry.token, + createTask6GameState({ + currentEncounter: { + kind: 'npc', + id: 'npc_scout_02', + npcName: '追迹人', + npcDescription: '擅长沿痕追人的同路者', + context: '山道追迹', + characterId: 'trail-scout', + }, + npcInteractionActive: true, + npcStates: { + npc_scout_02: { + affinity: 71, + chattedCount: 3, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + npc_old_guard: { + affinity: 48, + chattedCount: 1, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: true, + }, + npc_old_medic: { + affinity: 55, + chattedCount: 1, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: true, + }, + }, + companions: [ + { + npcId: 'npc_old_guard', + characterId: 'old-guard', + joinedAtAffinity: 48, + hp: 180, + maxHp: 180, + mana: 999, + maxMana: 999, + skillCooldowns: {}, + animationState: 'idle', + actionMode: 'idle', + offsetX: 0, + offsetY: 0, + transitionMs: 0, + }, + { + npcId: 'npc_old_medic', + characterId: 'old-medic', + joinedAtAffinity: 55, + hp: 170, + maxHp: 170, + mana: 999, + maxMana: 999, + skillCooldowns: {}, + animationState: 'idle', + actionMode: 'idle', + offsetX: 0, + offsetY: 0, + transitionMs: 0, + }, + ], + roster: [], + }), + ); + + const response = await httpRequest( + `${baseUrl}/api/runtime/story/actions/resolve`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: 0, + action: { + type: 'story_choice', + functionId: 'npc_recruit', + payload: { + releaseNpcId: 'npc_old_guard', + preludeText: + '追迹人:如果你真要带我同行,那就先把你队里的位置理顺。', + }, + }, + }), + }), + ); + const payload = (await response.json()) as { + snapshot: { + gameState: { + companions: Array<{ npcId: string }>; + roster: Array<{ npcId: string }>; + }; + }; + viewModel: { + companions: Array<{ npcId: string }>; + }; + }; + + assert.equal(response.status, 200); + assert.deepEqual( + payload.snapshot.gameState.companions.map((companion) => companion.npcId), + ['npc_scout_02', 'npc_old_medic'], + ); + assert.deepEqual( + payload.snapshot.gameState.roster.map((companion) => companion.npcId), + ['npc_old_guard'], + ); + assert.deepEqual( + payload.viewModel.companions.map((companion) => companion.npcId), + ['npc_scout_02', 'npc_old_medic'], + ); + }); +}); + test('runtime story actions resolve npc_trade buy transactions on the server', async () => { await withTestServer('task6-trade-buy', async ({ baseUrl }) => { const entry = await authEntry( diff --git a/server-node/src/modules/story/storyActionRoutes.ts b/server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts similarity index 80% rename from server-node/src/modules/story/storyActionRoutes.ts rename to server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts index 857e0f21..f9012657 100644 --- a/server-node/src/modules/story/storyActionRoutes.ts +++ b/server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts @@ -4,16 +4,14 @@ import { z } from 'zod'; import type { RuntimeStoryActionRequest, RuntimeStoryStateRequest, -} from '../../../../packages/shared/src/contracts/story.js'; +} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; import type { AppContext } from '../../context.js'; import { badRequest } from '../../errors.js'; import { asyncHandler, sendApiResponse } from '../../http.js'; import { requireJwtAuth } from '../../middleware/auth.js'; import { routeMeta } from '../../middleware/routeMeta.js'; -import { - getRuntimeStoryState, - resolveRuntimeStoryAction, -} from './storyActionService.js'; +import { getRuntimeStoryState } from '../../modules/rpg-runtime-story/RpgRuntimeStoryStateService.js'; +import { resolveRuntimeStoryAction } from '../../modules/rpg-runtime-story/RpgRuntimeStoryActionService.js'; const actionPayloadSchema = z.record(z.string(), z.unknown()); @@ -35,7 +33,9 @@ const runtimeStoryStateResolveSchema = z.object({ snapshot: z.unknown().optional(), }); -export function createStoryActionRoutes(context: AppContext) { +export const RPG_RUNTIME_STORY_ROUTE_BASE_PATH = '/api/runtime/story'; + +export function createRpgRuntimeStoryRoutes(context: AppContext) { const router = Router(); const requireAuth = requireJwtAuth(context.config, context.userRepository); @@ -51,7 +51,7 @@ export function createStoryActionRoutes(context: AppContext) { sendApiResponse( response, await resolveRuntimeStoryAction({ - runtimeRepository: context.runtimeRepository, + snapshotRepository: context.rpgRuntimeSnapshotRepository, llmClient: context.llmClient, userId: request.userId!, request: payload, @@ -72,7 +72,7 @@ export function createStoryActionRoutes(context: AppContext) { sendApiResponse( response, await getRuntimeStoryState({ - runtimeRepository: context.runtimeRepository, + snapshotRepository: context.rpgRuntimeSnapshotRepository, userId: request.userId!, sessionId, }), @@ -90,7 +90,7 @@ export function createStoryActionRoutes(context: AppContext) { sendApiResponse( response, await getRuntimeStoryState({ - runtimeRepository: context.runtimeRepository, + snapshotRepository: context.rpgRuntimeSnapshotRepository, userId: request.userId!, sessionId: payload.sessionId, clientVersion: payload.clientVersion, diff --git a/server-node/src/routes/rpgRouteBoundaries.test.ts b/server-node/src/routes/rpgRouteBoundaries.test.ts new file mode 100644 index 00000000..820e317e --- /dev/null +++ b/server-node/src/routes/rpgRouteBoundaries.test.ts @@ -0,0 +1,524 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import type { AddressInfo } from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { createApp } from '../app.ts'; +import type { AppConfig } from '../config.ts'; +import { createAppContext } from '../server.ts'; +import { httpRequest, type TestRequestInit } from '../testHttp.ts'; + +function createTestConfig(testName: string): AppConfig { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), `genarrative-rpg-routes-${testName}-`), + ); + + return { + nodeEnv: 'test', + projectRoot: tempRoot, + publicDir: path.join(tempRoot, 'public'), + logsDir: path.join(tempRoot, 'logs'), + dataDir: path.join(tempRoot, 'data'), + rawEnv: {}, + databaseUrl: `pg-mem://genarrative-rpg-routes-${testName}`, + serverAddr: ':0', + logLevel: 'silent', + editorApiEnabled: true, + assetsApiEnabled: true, + jwtSecret: 'test-secret', + jwtExpiresIn: '7d', + jwtIssuer: 'genarrative-rpg-routes-test', + llm: { + baseUrl: 'https://example.invalid', + apiKey: '', + model: 'test-model', + }, + dashScope: { + baseUrl: 'https://example.invalid', + apiKey: '', + imageModel: 'test-image-model', + requestTimeoutMs: 1000, + }, + smsAuth: { + enabled: true, + provider: 'mock', + endpoint: 'dypnsapi.aliyuncs.com', + accessKeyId: '', + accessKeySecret: '', + signName: 'Test Sign', + templateCode: '100001', + templateParamKey: 'code', + countryCode: '86', + schemeName: '', + codeLength: 6, + codeType: 1, + validTimeSeconds: 300, + intervalSeconds: 60, + duplicatePolicy: 1, + caseAuthPolicy: 1, + returnVerifyCode: false, + mockVerifyCode: '123456', + maxSendPerPhonePerDay: 20, + maxSendPerIpPerHour: 30, + maxVerifyFailuresPerPhonePerHour: 12, + maxVerifyFailuresPerIpPerHour: 24, + captchaTtlSeconds: 180, + captchaTriggerVerifyFailuresPerPhone: 3, + captchaTriggerVerifyFailuresPerIp: 5, + blockPhoneFailureThreshold: 6, + blockIpFailureThreshold: 10, + blockPhoneDurationMinutes: 30, + blockIpDurationMinutes: 30, + }, + wechatAuth: { + enabled: true, + provider: 'mock', + appId: '', + appSecret: '', + authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect', + accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token', + userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo', + callbackPath: '/api/auth/wechat/callback', + defaultRedirectPath: '/', + mockUserId: 'mock_wechat_user', + mockUnionId: 'mock_wechat_union', + mockDisplayName: '微信旅人', + mockAvatarUrl: '', + }, + authSession: { + accessCookieName: 'genarrative_access_session', + accessCookieTtlSeconds: 7200, + accessCookieSecure: false, + accessCookieSameSite: 'Lax', + accessCookiePath: '/', + refreshCookieName: 'genarrative_refresh_session', + refreshSessionTtlDays: 30, + refreshCookieSecure: false, + refreshCookieSameSite: 'Lax', + refreshCookiePath: '/api/auth', + }, + }; +} + +async function withTestServer( + testName: string, + run: (options: { baseUrl: string }) => Promise, +) { + const context = await createAppContext(createTestConfig(testName)); + const app = createApp(context); + const server = await new Promise((resolve) => { + const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer)); + }); + + try { + const address = server.address() as AddressInfo; + return await run({ + baseUrl: `http://127.0.0.1:${address.port}`, + }); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + await context.db.close(); + } +} + +async function authEntry(baseUrl: string, username: string, password: string) { + const response = await httpRequest(`${baseUrl}/api/auth/entry`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username, + password, + }), + }); + const payload = (await response.json()) as { + token: string; + user: { + id: string; + }; + }; + + assert.equal(response.status, 200); + assert.ok(payload.token); + return payload; +} + +function withBearer(token: string, init: TestRequestInit = {}) { + return { + ...init, + headers: { + ...(init.headers ?? {}), + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + } satisfies TestRequestInit; +} + +async function putSnapshot( + baseUrl: string, + token: string, + body: Record, +) { + const response = await httpRequest( + `${baseUrl}/api/runtime/save/snapshot`, + withBearer(token, { + method: 'PUT', + body: JSON.stringify(body), + }), + ); + + assert.equal(response.status, 200); + return response.json(); +} + +test('rpg profile routes keep new and legacy dashboard compatibility', async () => { + await withTestServer('profile-compat', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'rpg_profile_user', 'secret123'); + + await putSnapshot(baseUrl, entry.token, { + gameState: { + currentScene: 'Story', + worldType: 'WUXIA', + playerCharacter: { + id: 'hero-profile', + title: '试剑客', + description: '赶路的人。', + personality: '稳重', + attributes: { + strength: 8, + }, + skills: [], + }, + }, + bottomTab: 'adventure', + currentStory: { + text: '第一段记录', + options: [], + }, + savedAt: '2026-04-21T10:00:00.000Z', + }); + + const runtimeResponse = await httpRequest( + `${baseUrl}/api/runtime/profile/dashboard`, + withBearer(entry.token), + ); + const runtimePayload = (await runtimeResponse.json()) as { + walletBalance: number; + playedWorldCount: number; + }; + const legacyResponse = await httpRequest( + `${baseUrl}/api/profile/dashboard`, + withBearer(entry.token), + ); + const legacyPayload = (await legacyResponse.json()) as typeof runtimePayload; + + assert.equal(runtimeResponse.status, 200); + assert.equal(legacyResponse.status, 200); + assert.deepEqual(legacyPayload, runtimePayload); + }); +}); + +test('rpg entry save routes keep list and resume archive compatibility', async () => { + await withTestServer('save-archive-compat', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'rpg_save_user', 'secret123'); + + await putSnapshot(baseUrl, entry.token, { + gameState: { + currentScene: 'Story', + worldType: 'CUSTOM', + customWorldProfile: { + id: 'world-archive-a', + name: '裂潮边城', + }, + playerCharacter: { + id: 'hero-save', + title: '归乡人', + description: '带着旧信回城。', + personality: '沉静', + attributes: { + spirit: 9, + }, + skills: [], + }, + playerCurrency: 42, + }, + bottomTab: 'adventure', + currentStory: { + text: '旧灯塔还亮着。', + options: [], + }, + savedAt: '2026-04-21T10:05:00.000Z', + }); + + const listRuntime = await httpRequest( + `${baseUrl}/api/runtime/profile/save-archives`, + withBearer(entry.token), + ); + const listLegacy = await httpRequest( + `${baseUrl}/api/profile/save-archives`, + withBearer(entry.token), + ); + const runtimePayload = (await listRuntime.json()) as { + entries: Array<{ worldKey: string }>; + }; + const legacyPayload = (await listLegacy.json()) as typeof runtimePayload; + + assert.equal(listRuntime.status, 200); + assert.equal(listLegacy.status, 200); + assert.deepEqual(legacyPayload.entries, runtimePayload.entries); + assert.equal(runtimePayload.entries.length, 1); + + const worldKey = runtimePayload.entries[0]?.worldKey; + assert.ok(worldKey); + + const resumeRuntime = await httpRequest( + `${baseUrl}/api/runtime/profile/save-archives/${encodeURIComponent(worldKey!)}`, + withBearer(entry.token, { + method: 'POST', + }), + ); + const resumeLegacy = await httpRequest( + `${baseUrl}/api/profile/save-archives/${encodeURIComponent(worldKey!)}`, + withBearer(entry.token, { + method: 'POST', + }), + ); + const resumeRuntimePayload = (await resumeRuntime.json()) as { + entry: { worldKey: string }; + snapshot: { gameState: { playerCurrency: number } }; + }; + const resumeLegacyPayload = (await resumeLegacy.json()) as typeof resumeRuntimePayload; + + assert.equal(resumeRuntime.status, 200); + assert.equal(resumeLegacy.status, 200); + assert.deepEqual(resumeLegacyPayload.entry, resumeRuntimePayload.entry); + assert.equal( + resumeLegacyPayload.snapshot.bottomTab, + resumeRuntimePayload.snapshot.bottomTab, + ); + assert.equal( + resumeLegacyPayload.snapshot.currentStory.text, + resumeRuntimePayload.snapshot.currentStory.text, + ); + assert.equal(resumeRuntimePayload.snapshot.gameState.playerCurrency, 42); + assert.equal(resumeLegacyPayload.snapshot.gameState.playerCurrency, 42); + }); +}); + +test('rpg world library routes expose gallery and library through new boundaries', async () => { + await withTestServer('world-library-boundary', async ({ baseUrl }) => { + const owner = await authEntry(baseUrl, 'rpg_world_owner', 'secret123'); + + const upsertResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library/world-a`, + withBearer(owner.token, { + method: 'PUT', + body: JSON.stringify({ + profile: { + name: '裂桥前线', + subtitle: '雾潮压城', + summary: '守桥与沉船商盟持续拉扯。', + settingText: '一座被雾潮包住的边城。', + templateWorldType: 'WUXIA', + majorFactions: [], + coreConflicts: [], + playableNpcs: [], + storyNpcs: [], + items: [], + landmarks: [], + attributeSchema: { + slots: [], + }, + }, + }), + }), + ); + assert.equal(upsertResponse.status, 200); + + const libraryResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library`, + withBearer(owner.token), + ); + const libraryPayload = (await libraryResponse.json()) as { + entries: Array<{ profileId: string }>; + }; + assert.equal(libraryResponse.status, 200); + assert.deepEqual( + libraryPayload.entries.map((entry) => entry.profileId), + ['world-a'], + ); + + const publishResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library/world-a/publish`, + withBearer(owner.token, { + method: 'POST', + }), + ); + assert.equal(publishResponse.status, 200); + + const galleryResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-gallery`, + ); + const galleryPayload = (await galleryResponse.json()) as { + entries: Array<{ ownerUserId: string; profileId: string }>; + }; + assert.equal(galleryResponse.status, 200); + assert.equal(galleryPayload.entries.length, 1); + + const detailResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-gallery/${encodeURIComponent(galleryPayload.entries[0]!.ownerUserId)}/${encodeURIComponent(galleryPayload.entries[0]!.profileId)}`, + ); + const detailPayload = (await detailResponse.json()) as { + entry: { + profileId: string; + worldName: string; + }; + }; + assert.equal(detailResponse.status, 200); + assert.equal(detailPayload.entry.profileId, 'world-a'); + assert.equal(detailPayload.entry.worldName, '裂桥前线'); + }); +}); + +test('rpg runtime story routes resolve through the new route boundary', async () => { + await withTestServer('runtime-story-boundary', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'rpg_story_user', 'secret123'); + + await putSnapshot(baseUrl, entry.token, { + gameState: { + worldType: 'WUXIA', + playerCharacter: { + id: 'hero-story', + title: '试剑客', + description: '站在桥口的人。', + personality: '谨慎', + attributes: { + strength: 8, + spirit: 6, + }, + skills: [], + }, + runtimeStats: { + playTimeMs: 0, + lastPlayTickAt: null, + hostileNpcsDefeated: 0, + questsAccepted: 0, + itemsUsed: 0, + scenesTraveled: 0, + }, + currentScene: 'test-scene', + storyHistory: [], + characterChats: {}, + animationState: 'idle', + currentEncounter: { + kind: 'npc', + id: 'npc_merchant_01', + npcName: '沈七', + npcDescription: '腰间挂着药囊的行商', + context: '受伤行商', + }, + npcInteractionActive: true, + currentScenePreset: null, + sceneHostileNpcs: [], + playerX: 0, + playerOffsetY: 0, + playerFacing: 'right', + playerActionMode: 'idle', + scrollWorld: false, + inBattle: false, + playerHp: 31, + playerMaxHp: 40, + playerMana: 9, + playerMaxMana: 16, + playerSkillCooldowns: {}, + activeBuildBuffs: [], + activeCombatEffects: [], + playerCurrency: 90, + playerInventory: [], + playerEquipment: { + weapon: null, + armor: null, + relic: null, + }, + npcStates: { + npc_merchant_01: { + affinity: 46, + chattedCount: 0, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + }, + quests: [], + roster: [], + companions: [], + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + sparReturnEncounter: null, + sparPlayerHpBefore: null, + sparPlayerMaxHpBefore: null, + sparStoryHistoryBefore: null, + }, + bottomTab: 'adventure', + currentStory: { + text: '巡路人看着你,像在等一句开口。', + options: [], + }, + }); + + const stateResponse = await httpRequest( + `${baseUrl}/api/runtime/story/state/runtime-main`, + withBearer(entry.token), + ); + const statePayload = (await stateResponse.json()) as { + viewModel: { + availableOptions: Array<{ functionId: string }>; + }; + }; + assert.equal(stateResponse.status, 200); + assert.ok( + statePayload.viewModel.availableOptions.some( + (option) => option.functionId === 'npc_chat', + ), + ); + + const actionResponse = await httpRequest( + `${baseUrl}/api/runtime/story/actions/resolve`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: 0, + action: { + type: 'story_choice', + functionId: 'npc_chat', + }, + }), + }), + ); + const actionPayload = (await actionResponse.json()) as { + serverVersion: number; + viewModel: { + encounter: { + affinity: number; + } | null; + }; + }; + + assert.equal(actionResponse.status, 200); + assert.equal(actionPayload.serverVersion, 1); + assert.equal(actionPayload.viewModel.encounter?.affinity, 52); + }); +}); diff --git a/server-node/src/routes/rpgWorldLibraryRoutes.ts b/server-node/src/routes/rpgWorldLibraryRoutes.ts deleted file mode 100644 index cdd4fcbc..00000000 --- a/server-node/src/routes/rpgWorldLibraryRoutes.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Router } from 'express'; - -import type { AppContext } from '../context.js'; - -/** - * 工作包 A 先建立 RPG 世界作品库路由的命名骨架。 - * 当前仅提供稳定落点,真正的库读写逻辑仍保留在 `runtimeRoutes.ts` 中。 - */ -export const RPG_WORLD_LIBRARY_ROUTE_BASE_PATH = '/runtime/custom-world-library'; - -export function createRpgWorldLibraryRoutes(_context: AppContext) { - return Router(); -} diff --git a/server-node/src/routes/rpgWorldWorksRoutes.ts b/server-node/src/routes/rpgWorldWorksRoutes.ts deleted file mode 100644 index f8488c45..00000000 --- a/server-node/src/routes/rpgWorldWorksRoutes.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Router } from 'express'; - -import type { AppContext } from '../context.js'; - -/** - * 工作包 A 先建立 RPG 世界作品流路由的命名骨架。 - * 真实实现仍暂挂在 `runtimeRoutes.ts`,后续工作包再把作品列表接口迁入这里。 - */ -export const RPG_WORLD_WORKS_ROUTE_BASE_PATH = '/runtime/custom-world/works'; - -export function createRpgWorldWorksRoutes(_context: AppContext) { - return Router(); -} diff --git a/server-node/src/routes/runtimeRoutes.ts b/server-node/src/routes/runtimeRoutes.ts deleted file mode 100644 index ddcd13d5..00000000 --- a/server-node/src/routes/runtimeRoutes.ts +++ /dev/null @@ -1,843 +0,0 @@ -import { Router } from 'express'; -import { z } from 'zod'; - -import type { ListCustomWorldWorksResponse } from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import type { - CustomWorldGalleryDetailResponse, - CustomWorldGalleryResponse, - CustomWorldLibraryMutationResponse, - CustomWorldLibraryResponse, - GenerateCustomWorldProfileInput, - PlatformBrowseHistoryBatchSyncRequest, - PlatformBrowseHistoryResponse, - PlatformBrowseHistoryWriteEntry, - ProfileDashboardSummary, - ProfilePlayStatsResponse, - ProfileSaveArchiveListResponse, - ProfileSaveArchiveResumeResponse, - ProfileWalletLedgerResponse, - RuntimeSettings, - SavedGameSnapshotInput, -} from '../../../packages/shared/src/contracts/runtime.js'; -import { - PLATFORM_THEMES, -} from '../../../packages/shared/src/contracts/runtime.js'; -import type { - QuestGenerationRequest, - RuntimeItemIntentRequest, -} from '../../../packages/shared/src/contracts/story.js'; -import type { - CharacterChatReplyRequest, - CharacterChatSuggestionsRequest, - CharacterChatSummaryRequest, - NpcChatDialogueRequest, - NpcChatTurnRequest, - NpcRecruitDialogueRequest, -} from '../../../packages/shared/src/contracts/story.js'; -import type { AppContext } from '../context.js'; -import { badRequest, notFound } from '../errors.js'; -import { - asyncHandler, - jsonClone, - sendApiResponse, -} from '../http.js'; -import { requireJwtAuth } from '../middleware/auth.js'; -import { routeMeta } from '../middleware/routeMeta.js'; -import { - generateCharacterChatSuggestionsFromOrchestrator, - generateCharacterChatSummaryFromOrchestrator, - streamCharacterChatReplyFromOrchestrator, - streamNpcChatDialogueFromOrchestrator, - streamNpcChatTurnFromOrchestrator, - streamNpcRecruitDialogueFromOrchestrator, -} from '../modules/ai/chatOrchestrator.js'; -import { generateCustomWorldProfileFromOrchestrator } from '../modules/ai/customWorldOrchestrator.js'; -import { - hydrateSavedSnapshot, - normalizeSavedSnapshotPayload, -} from '../modules/runtime/runtimeSnapshotHydration.js'; -import { - characterChatReplyRequestSchema, - characterChatSuggestionsRequestSchema, - characterChatSummaryRequestSchema, - npcChatDialogueRequestSchema, - npcChatTurnRequestSchema, - npcRecruitDialogueRequestSchema, -} from '../services/chatService.js'; -import { generateCustomWorldEntity } from '../services/customWorldEntityGenerationService.js'; -import { generateSceneNpcForLandmark } from '../services/customWorldSceneNpcGenerationService.js'; -import { listCustomWorldWorkSummaries } from '../services/customWorldWorkSummaryService.js'; -import { generateQuestForNpcEncounter } from '../services/questService.js'; -import { generateRuntimeItemIntents } from '../services/runtimeItemService.js'; -import { - customWorldCoverImageSchema, - customWorldCoverUploadSchema, - generateCustomWorldCoverImage, - uploadCustomWorldCoverImage, -} from '../services/customWorldCoverAssetService.js'; -import { - generateSceneImage, - sceneImageSchema, -} from '../services/sceneImageService.js'; -import { - generateHighQualityInitialStory, - generateHighQualityNextStory, - parseStoryRequest, -} from '../services/storyService.js'; -import { createCustomWorldAgentRoutes } from './customWorldAgent.js'; - -const jsonObjectSchema = z.record(z.string(), z.unknown()); - -const saveSnapshotSchema = z.object({ - gameState: z.unknown(), - bottomTab: z.string().trim().min(1), - currentStory: z.unknown().nullable().optional().default(null), - savedAt: z.string().trim().optional().default(''), -}); - -const settingsSchema = z.object({ - musicVolume: z.number().min(0).max(1), - platformTheme: z.enum(PLATFORM_THEMES), -}); - -const platformBrowseHistoryEntrySchema = z.object({ - ownerUserId: z.string().trim().min(1), - profileId: z.string().trim().min(1), - worldName: z.string().trim().min(1), - subtitle: z.string().trim().optional().default(''), - summaryText: z.string().trim().optional().default(''), - coverImageSrc: z.string().trim().nullable().optional().default(null), - themeMode: z.string().trim().optional().default('mythic'), - authorDisplayName: z.string().trim().optional().default('玩家'), - visitedAt: z.string().trim().optional().default(''), -}); - -const platformBrowseHistoryBatchSchema = z.object({ - entries: z.array(platformBrowseHistoryEntrySchema).max(100), -}); - -const customWorldProfileSchema = z.object({ - profile: jsonObjectSchema, -}); - -const customWorldProfileGenerationSchema = z.object({ - settingText: z.string().trim().min(1), - creatorIntent: jsonObjectSchema.nullish(), - generationMode: z.enum(['fast', 'full']).optional(), -}); - -const customWorldSceneNpcSchema = z.object({ - profile: jsonObjectSchema, - landmarkId: z.string().trim().min(1), -}); - -const customWorldEntitySchema = z.object({ - profile: jsonObjectSchema, - kind: z.enum(['playable', 'story', 'landmark']), -}); - -const runtimeItemIntentSchema = z.object({ - context: jsonObjectSchema, - plans: z.array(jsonObjectSchema), -}); - -const questGenerationSchema = z.object({ - state: jsonObjectSchema, - encounter: jsonObjectSchema, -}); - -const llmProxySchema = jsonObjectSchema; - -function readParam(param: string | string[] | undefined) { - return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || ''; -} - -async function resolveAuthDisplayName(context: AppContext, userId: string) { - const user = await context.userRepository.findById(userId); - if (!user) { - throw notFound('user not found'); - } - - return user.displayName?.trim() || '玩家'; -} - -export function createRuntimeRoutes(context: AppContext) { - const router = Router(); - const requireAuth = requireJwtAuth(context.config, context.userRepository); - const routeCompatPaths = (path: string) => [ - path, - `/runtime${path}`, - ] as const; - const handleCustomWorldEntityGeneration = asyncHandler(async (request, response) => { - const payload = customWorldEntitySchema.parse(request.body) as { - profile: Record; - kind: 'playable' | 'story' | 'landmark'; - }; - sendApiResponse( - response, - await generateCustomWorldEntity(context.llmClient, payload), - ); - }); - const handleCustomWorldSceneNpcGeneration = asyncHandler(async (request, response) => { - const payload = customWorldSceneNpcSchema.parse(request.body) as { - profile: Record; - landmarkId: string; - }; - sendApiResponse(response, { - npc: await generateSceneNpcForLandmark(context.llmClient, payload), - }); - }); - - router.get( - '/runtime/custom-world-gallery', - routeMeta({ operation: 'runtime.customWorldGallery.list' }), - asyncHandler(async (_request, response) => { - sendApiResponse(response, { - entries: await context.runtimeRepository.listPublishedCustomWorldGallery(), - } satisfies CustomWorldGalleryResponse); - }), - ); - - router.get( - '/runtime/custom-world-gallery/:ownerUserId/:profileId', - routeMeta({ operation: 'runtime.customWorldGallery.detail' }), - asyncHandler(async (request, response) => { - const ownerUserId = readParam(request.params.ownerUserId); - const profileId = readParam(request.params.profileId); - if (!ownerUserId || !profileId) { - throw badRequest('ownerUserId and profileId are required'); - } - - const entry = - await context.runtimeRepository.getPublishedCustomWorldGalleryDetail( - ownerUserId, - profileId, - ); - if (!entry) { - throw notFound('public custom world not found'); - } - - sendApiResponse(response, { - entry, - } satisfies CustomWorldGalleryDetailResponse); - }), - ); - - router.use(requireAuth); - router.use( - '/runtime/custom-world/agent', - createCustomWorldAgentRoutes(context), - ); - - routeCompatPaths('/profile/dashboard').forEach((path, index) => { - router.get( - path, - routeMeta({ - operation: - index === 0 - ? 'profile.dashboard.get' - : 'profile.dashboard.get.compat', - }), - asyncHandler(async (request, response) => { - sendApiResponse( - response, - await context.runtimeRepository.getProfileDashboard(request.userId!), - ); - }), - ); - }); - - routeCompatPaths('/profile/wallet-ledger').forEach((path, index) => { - router.get( - path, - routeMeta({ - operation: - index === 0 - ? 'profile.walletLedger.list' - : 'profile.walletLedger.list.compat', - }), - asyncHandler(async (request, response) => { - sendApiResponse(response, { - entries: await context.runtimeRepository.listProfileWalletLedger( - request.userId!, - ), - }); - }), - ); - }); - - routeCompatPaths('/profile/play-stats').forEach((path, index) => { - router.get( - path, - routeMeta({ - operation: - index === 0 - ? 'profile.playStats.get' - : 'profile.playStats.get.compat', - }), - asyncHandler(async (request, response) => { - sendApiResponse( - response, - await context.runtimeRepository.getProfilePlayStats(request.userId!), - ); - }), - ); - }); - - routeCompatPaths('/profile/browse-history').forEach((path, index) => { - router.get( - path, - routeMeta({ - operation: - index === 0 - ? 'profile.browseHistory.list' - : 'profile.browseHistory.list.compat', - }), - asyncHandler(async (request, response) => { - sendApiResponse(response, { - entries: await context.runtimeRepository.listPlatformBrowseHistory( - request.userId!, - ), - }); - }), - ); - - router.post( - path, - routeMeta({ - operation: - index === 0 - ? 'profile.browseHistory.upsert' - : 'profile.browseHistory.upsert.compat', - }), - asyncHandler(async (request, response) => { - const rawBody = - request.body && typeof request.body === 'object' ? request.body : {}; - const payload = ( - 'entries' in rawBody - ? platformBrowseHistoryBatchSchema.parse(rawBody) - : platformBrowseHistoryEntrySchema.parse(rawBody) - ) as - | PlatformBrowseHistoryBatchSyncRequest - | PlatformBrowseHistoryWriteEntry; - - const entries = 'entries' in payload ? payload.entries : [payload]; - - sendApiResponse(response, { - entries: - await context.runtimeRepository.upsertPlatformBrowseHistoryEntries( - request.userId!, - entries, - ), - }); - }), - ); - - router.delete( - path, - routeMeta({ - operation: - index === 0 - ? 'profile.browseHistory.clear' - : 'profile.browseHistory.clear.compat', - }), - asyncHandler(async (request, response) => { - await context.runtimeRepository.clearPlatformBrowseHistory( - request.userId!, - ); - sendApiResponse(response, { - entries: [], - }); - }), - ); - }); - - routeCompatPaths('/profile/save-archives').forEach((path, index) => { - router.get( - path, - routeMeta({ - operation: - index === 0 - ? 'profile.saveArchives.list' - : 'profile.saveArchives.list.compat', - }), - asyncHandler(async (request, response) => { - sendApiResponse(response, { - entries: await context.runtimeRepository.listProfileSaveArchives( - request.userId!, - ), - }); - }), - ); - }); - - [ - '/profile/save-archives/:worldKey', - '/runtime/profile/save-archives/:worldKey', - ].forEach((path, index) => { - router.post( - path, - routeMeta({ - operation: - index === 0 - ? 'profile.saveArchives.resume' - : 'profile.saveArchives.resume.compat', - }), - asyncHandler(async (request, response) => { - const worldKey = - typeof request.params.worldKey === 'string' - ? request.params.worldKey.trim() - : ''; - - if (!worldKey) { - throw badRequest('worldKey 不能为空'); - } - - const resumedArchive = - await context.runtimeRepository.resumeProfileSaveArchive( - request.userId!, - worldKey, - ); - - if (!resumedArchive) { - throw notFound('指定存档不存在'); - } - - sendApiResponse(response, { - entry: resumedArchive.entry, - snapshot: hydrateSavedSnapshot(resumedArchive.snapshot)!, - }); - }), - ); - }); - - router.post( - '/llm/chat/completions', - routeMeta({ operation: 'runtime.llm.chatCompletionsProxy' }), - asyncHandler(async (request, response) => { - const body = llmProxySchema.parse(request.body); - await context.llmClient.forwardCompletion(request, body, response); - }), - ); - - router.post( - '/custom-world/cover-image', - routeMeta({ operation: 'runtime.customWorld.coverImage' }), - asyncHandler(async (request, response) => { - const payload = customWorldCoverImageSchema.parse(request.body); - sendApiResponse(response, await generateCustomWorldCoverImage(context, payload)); - }), - ); - - router.post( - '/custom-world/cover-upload', - routeMeta({ operation: 'runtime.customWorld.coverUpload' }), - asyncHandler(async (request, response) => { - const payload = customWorldCoverUploadSchema.parse(request.body); - sendApiResponse(response, await uploadCustomWorldCoverImage(context, payload)); - }), - ); - - router.post( - '/custom-world/scene-image', - routeMeta({ operation: 'runtime.customWorld.sceneImage' }), - asyncHandler(async (request, response) => { - const payload = sceneImageSchema.parse(request.body); - sendApiResponse(response, await generateSceneImage(context, payload)); - }), - ); - - router.post( - '/custom-world/entity', - routeMeta({ operation: 'runtime.customWorld.entity' }), - handleCustomWorldEntityGeneration, - ); - - router.post( - '/runtime/custom-world/entity', - routeMeta({ operation: 'runtime.customWorld.entity.compat' }), - handleCustomWorldEntityGeneration, - ); - - router.post( - '/custom-world/scene-npc', - routeMeta({ operation: 'runtime.customWorld.sceneNpc' }), - handleCustomWorldSceneNpcGeneration, - ); - - router.post( - '/runtime/custom-world/scene-npc', - routeMeta({ operation: 'runtime.customWorld.sceneNpc.compat' }), - handleCustomWorldSceneNpcGeneration, - ); - - router.get( - '/runtime/save/snapshot', - routeMeta({ operation: 'runtime.snapshot.get' }), - asyncHandler(async (request, response) => { - sendApiResponse( - response, - hydrateSavedSnapshot( - await context.runtimeRepository.getSnapshot(request.userId!), - ), - ); - }), - ); - - router.put( - '/runtime/save/snapshot', - routeMeta({ operation: 'runtime.snapshot.put' }), - asyncHandler(async (request, response) => { - const payload = saveSnapshotSchema.parse( - request.body, - ) as SavedGameSnapshotInput; - const normalizedSnapshot = normalizeSavedSnapshotPayload({ - savedAt: payload.savedAt || new Date().toISOString(), - gameState: payload.gameState, - bottomTab: payload.bottomTab, - currentStory: payload.currentStory ?? null, - }); - sendApiResponse( - response, - hydrateSavedSnapshot( - await context.runtimeRepository.putSnapshot( - request.userId!, - normalizedSnapshot, - ), - ), - ); - }), - ); - - router.delete( - '/runtime/save/snapshot', - routeMeta({ operation: 'runtime.snapshot.delete' }), - asyncHandler(async (request, response) => { - await context.runtimeRepository.deleteSnapshot(request.userId!); - sendApiResponse(response, { ok: true }); - }), - ); - - router.get( - '/runtime/settings', - routeMeta({ operation: 'runtime.settings.get' }), - asyncHandler(async (request, response) => { - sendApiResponse( - response, - await context.runtimeRepository.getSettings(request.userId!), - ); - }), - ); - - router.put( - '/runtime/settings', - routeMeta({ operation: 'runtime.settings.put' }), - asyncHandler(async (request, response) => { - const payload = settingsSchema.parse(request.body) as RuntimeSettings; - sendApiResponse( - response, - await context.runtimeRepository.putSettings(request.userId!, payload), - ); - }), - ); - - router.get( - '/runtime/custom-world/works', - routeMeta({ operation: 'runtime.customWorldWorks.list' }), - asyncHandler(async (request, response) => { - sendApiResponse(response, { - items: await listCustomWorldWorkSummaries(request.userId!, { - runtimeRepository: context.runtimeRepository, - customWorldAgentSessions: context.customWorldAgentSessions, - }), - }); - }), - ); - - router.get( - '/runtime/custom-world-library', - routeMeta({ operation: 'runtime.customWorldLibrary.list' }), - asyncHandler(async (request, response) => { - sendApiResponse(response, { - entries: await context.runtimeRepository.listCustomWorldProfiles( - request.userId!, - ), - } satisfies CustomWorldLibraryResponse); - }), - ); - - router.put( - '/runtime/custom-world-library/:profileId', - routeMeta({ operation: 'runtime.customWorldLibrary.upsert' }), - asyncHandler(async (request, response) => { - const profileId = readParam(request.params.profileId); - if (!profileId) { - throw badRequest('profileId is required'); - } - const payload = customWorldProfileSchema.parse(request.body); - const authorDisplayName = await resolveAuthDisplayName( - context, - request.userId!, - ); - sendApiResponse( - response, - await context.runtimeRepository.upsertCustomWorldProfile( - request.userId!, - profileId, - jsonClone(payload.profile), - authorDisplayName, - ), - ); - }), - ); - - router.delete( - '/runtime/custom-world-library/:profileId', - routeMeta({ operation: 'runtime.customWorldLibrary.delete' }), - asyncHandler(async (request, response) => { - const profileId = readParam(request.params.profileId); - if (!profileId) { - throw badRequest('profileId is required'); - } - sendApiResponse(response, { - entries: await context.runtimeRepository.deleteCustomWorldProfile( - request.userId!, - profileId, - ), - } satisfies CustomWorldLibraryResponse); - }), - ); - - router.post( - '/runtime/custom-world/profile', - routeMeta({ operation: 'runtime.customWorld.profile' }), - asyncHandler(async (request, response) => { - const payload = customWorldProfileGenerationSchema.parse( - request.body, - ) as GenerateCustomWorldProfileInput; - sendApiResponse( - response, - await generateCustomWorldProfileFromOrchestrator( - context.llmClient, - payload, - ), - ); - }), - ); - - router.post( - '/runtime/custom-world-library/:profileId/publish', - routeMeta({ operation: 'runtime.customWorldLibrary.publish' }), - asyncHandler(async (request, response) => { - const profileId = readParam(request.params.profileId); - if (!profileId) { - throw badRequest('profileId is required'); - } - - const authorDisplayName = await resolveAuthDisplayName( - context, - request.userId!, - ); - const mutation = - await context.runtimeRepository.publishCustomWorldProfile( - request.userId!, - profileId, - authorDisplayName, - ); - if (!mutation) { - throw notFound('custom world not found'); - } - - sendApiResponse( - response, - mutation satisfies CustomWorldLibraryMutationResponse, - ); - }), - ); - - router.post( - '/runtime/custom-world-library/:profileId/unpublish', - routeMeta({ operation: 'runtime.customWorldLibrary.unpublish' }), - asyncHandler(async (request, response) => { - const profileId = readParam(request.params.profileId); - if (!profileId) { - throw badRequest('profileId is required'); - } - - const authorDisplayName = await resolveAuthDisplayName( - context, - request.userId!, - ); - const mutation = - await context.runtimeRepository.unpublishCustomWorldProfile( - request.userId!, - profileId, - authorDisplayName, - ); - if (!mutation) { - throw notFound('custom world not found'); - } - - sendApiResponse( - response, - mutation satisfies CustomWorldLibraryMutationResponse, - ); - }), - ); - - router.post( - '/runtime/story/initial', - routeMeta({ operation: 'runtime.story.initial' }), - asyncHandler(async (request, response) => { - const payload = parseStoryRequest(request.body); - sendApiResponse( - response, - await generateHighQualityInitialStory(context.llmClient, payload), - ); - }), - ); - - router.post( - '/runtime/story/continue', - routeMeta({ operation: 'runtime.story.continue' }), - asyncHandler(async (request, response) => { - const payload = parseStoryRequest(request.body); - sendApiResponse( - response, - await generateHighQualityNextStory(context.llmClient, payload), - ); - }), - ); - - router.post( - '/runtime/chat/character/suggestions', - routeMeta({ operation: 'runtime.chat.character.suggestions' }), - asyncHandler(async (request, response) => { - const payload = characterChatSuggestionsRequestSchema.parse( - request.body, - ) as CharacterChatSuggestionsRequest; - sendApiResponse(response, { - text: await generateCharacterChatSuggestionsFromOrchestrator( - context.llmClient, - payload, - ), - }); - }), - ); - - router.post( - '/runtime/chat/character/summary', - routeMeta({ operation: 'runtime.chat.character.summary' }), - asyncHandler(async (request, response) => { - const payload = characterChatSummaryRequestSchema.parse( - request.body, - ) as CharacterChatSummaryRequest; - sendApiResponse(response, { - text: await generateCharacterChatSummaryFromOrchestrator( - context.llmClient, - payload, - ), - }); - }), - ); - - router.post( - '/runtime/chat/character/reply/stream', - routeMeta({ operation: 'runtime.chat.character.replyStream' }), - asyncHandler(async (request, response) => { - const payload = characterChatReplyRequestSchema.parse( - request.body, - ) as CharacterChatReplyRequest; - await streamCharacterChatReplyFromOrchestrator(context.llmClient, { - request, - response, - payload, - }); - }), - ); - - router.post( - '/runtime/chat/npc/dialogue/stream', - routeMeta({ operation: 'runtime.chat.npc.dialogueStream' }), - asyncHandler(async (request, response) => { - const payload = npcChatDialogueRequestSchema.parse( - request.body, - ) as NpcChatDialogueRequest; - await streamNpcChatDialogueFromOrchestrator(context.llmClient, { - request, - response, - payload, - }); - }), - ); - - router.post( - '/runtime/chat/npc/turn/stream', - routeMeta({ operation: 'runtime.chat.npc.turnStream' }), - asyncHandler(async (request, response) => { - const payload = npcChatTurnRequestSchema.parse( - request.body, - ) as NpcChatTurnRequest; - await streamNpcChatTurnFromOrchestrator(context.llmClient, { - request, - response, - payload, - }); - }), - ); - - router.post( - '/runtime/chat/npc/recruit/stream', - routeMeta({ operation: 'runtime.chat.npc.recruitStream' }), - asyncHandler(async (request, response) => { - const payload = npcRecruitDialogueRequestSchema.parse( - request.body, - ) as NpcRecruitDialogueRequest; - await streamNpcRecruitDialogueFromOrchestrator(context.llmClient, { - request, - response, - payload, - }); - }), - ); - - router.post( - '/runtime/items/runtime-intent', - routeMeta({ operation: 'runtime.items.intent' }), - asyncHandler(async (request, response) => { - const payload = runtimeItemIntentSchema.parse( - request.body, - ) as RuntimeItemIntentRequest; - sendApiResponse(response, { - intents: await generateRuntimeItemIntents(context.llmClient, payload), - }); - }), - ); - - router.post( - '/runtime/quests/generate', - routeMeta({ operation: 'runtime.quests.generate' }), - asyncHandler(async (request, response) => { - const payload = questGenerationSchema.parse( - request.body, - ) as QuestGenerationRequest; - sendApiResponse( - response, - await generateQuestForNpcEncounter(context.llmClient, payload), - ); - }), - ); - - router.get( - '/ws/health', - routeMeta({ operation: 'runtime.ws.health' }), - (_request, response) => { - sendApiResponse(response, { - ok: true, - message: 'websocket routes reserved for future real-time support', - }); - }, - ); - - return router; -} diff --git a/server-node/src/scripts/syncCustomWorldSavedProfileAssets.ts b/server-node/src/scripts/syncCustomWorldSavedProfileAssets.ts index d64c6c3e..eabe111e 100644 --- a/server-node/src/scripts/syncCustomWorldSavedProfileAssets.ts +++ b/server-node/src/scripts/syncCustomWorldSavedProfileAssets.ts @@ -4,8 +4,9 @@ import path from 'node:path'; import sharp from 'sharp'; import { createDatabase } from '../db.js'; -import { RuntimeRepository } from '../repositories/runtimeRepository.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; @@ -80,15 +81,18 @@ async function main() { const db = await createDatabase(config); try { - const runtimeRepository = new RuntimeRepository(db); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + 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 runtimeRepository.listCustomWorldProfiles(userId) + await rpgWorldProfileRepository.listOwnProfiles(userId) ).find((entry) => entry.profileId === profileId); if (!savedProfileEntry) { throw new Error('未找到目标 saved profile,无法同步历史保存档案。'); @@ -200,7 +204,7 @@ async function main() { nextProfile.landmarks = landmarks; nextProfile.sceneChapterBlueprints = sceneChapterBlueprints; - const updatedEntry = await runtimeRepository.upsertCustomWorldProfile( + const updatedEntry = await rpgWorldProfileRepository.upsertOwnProfile( userId, profileId, nextProfile, diff --git a/server-node/src/server.ts b/server-node/src/server.ts index 813da85a..62c05fa5 100644 --- a/server-node/src/server.ts +++ b/server-node/src/server.ts @@ -8,6 +8,13 @@ import { createLogger } from './logging.js'; import { AuthAuditLogRepository } from './repositories/authAuditLogRepository.js'; import { AuthIdentityRepository } from './repositories/authIdentityRepository.js'; import { AuthRiskBlockRepository } from './repositories/authRiskBlockRepository.js'; +import { RpgAgentSessionRepository } from './repositories/RpgAgentSessionRepository.js'; +import { RpgWorldProfileRepository } from './repositories/RpgWorldProfileRepository.js'; +import { RpgSaveArchiveRepository } from './repositories/rpg-entry/RpgSaveArchiveRepository.js'; +import { RpgWorldLibraryRepository } from './repositories/rpg-entry/RpgWorldLibraryRepository.js'; +import { RpgBrowseHistoryRepository } from './repositories/rpg-profile/RpgBrowseHistoryRepository.js'; +import { RpgProfileDashboardRepository } from './repositories/rpg-profile/RpgProfileDashboardRepository.js'; +import { RpgRuntimeSnapshotRepository } from './repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js'; import { RuntimeRepository } from './repositories/runtimeRepository.js'; import { SmsAuthEventRepository } from './repositories/smsAuthEventRepository.js'; import { UserRepository } from './repositories/userRepository.js'; @@ -17,6 +24,7 @@ import { CustomWorldAgentAutoAssetService } from './services/customWorldAgentAut import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js'; import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js'; import { UpstreamLlmClient } from './services/llmClient.js'; +import { RpgWorldWorkSummaryService } from './services/RpgWorldWorkSummaryService.js'; import { createSmsVerificationService } from './services/smsVerificationService.js'; import { createWechatAuthService } from './services/wechatAuthService.js'; import { WechatAuthStateStore } from './services/wechatAuthStateStore.js'; @@ -79,10 +87,32 @@ function describeDatabase(databaseUrl: string) { export async function createAppContext(config: AppConfig = loadConfig()) { const logger = createLogger(config); const db = await createDatabase(config); + const rpgAgentSessionRepository = new RpgAgentSessionRepository(db); + const rpgWorldProfileRepository = new RpgWorldProfileRepository(db); const runtimeRepository = new RuntimeRepository(db); - const customWorldAgentSessions = new CustomWorldAgentSessionStore( + const rpgProfileDashboardRepository = new RpgProfileDashboardRepository( runtimeRepository, ); + const rpgBrowseHistoryRepository = new RpgBrowseHistoryRepository( + runtimeRepository, + ); + const rpgSaveArchiveRepository = new RpgSaveArchiveRepository( + runtimeRepository, + ); + const rpgWorldLibraryRepository = new RpgWorldLibraryRepository( + runtimeRepository, + ); + const rpgRuntimeSnapshotRepository = new RpgRuntimeSnapshotRepository( + runtimeRepository, + ); + const userRepository = new UserRepository(db); + const customWorldAgentSessions = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); + const rpgWorldWorkSummaryService = new RpgWorldWorkSummaryService( + rpgWorldProfileRepository, + customWorldAgentSessions, + ); const autoAssetService = new CustomWorldAgentAutoAssetService( config, config.dashScope.apiKey.trim() @@ -104,12 +134,19 @@ export async function createAppContext(config: AppConfig = loadConfig()) { config, logger, db, - userRepository: new UserRepository(db), + userRepository, authIdentityRepository: new AuthIdentityRepository(db), authAuditLogRepository: new AuthAuditLogRepository(db), authRiskBlockRepository: new AuthRiskBlockRepository(db), smsAuthEventRepository: new SmsAuthEventRepository(db), userSessionRepository: new UserSessionRepository(db), + rpgAgentSessionRepository, + rpgWorldProfileRepository, + rpgProfileDashboardRepository, + rpgBrowseHistoryRepository, + rpgSaveArchiveRepository, + rpgWorldLibraryRepository, + rpgRuntimeSnapshotRepository, runtimeRepository, llmClient: new UpstreamLlmClient(config, logger), customWorldAgentSessions, @@ -120,8 +157,11 @@ export async function createAppContext(config: AppConfig = loadConfig()) { : null, { autoAssetService, + rpgWorldProfileRepository, + userRepository, }, ), + rpgWorldWorkSummaryService, smsVerificationService: createSmsVerificationService(config, logger), wechatAuthService: createWechatAuthService(config, logger), wechatAuthStates: new WechatAuthStateStore(), diff --git a/server-node/src/services/RpgWorldPreviewCompiler.fixture.test.ts b/server-node/src/services/RpgWorldPreviewCompiler.fixture.test.ts new file mode 100644 index 00000000..ad73d3be --- /dev/null +++ b/server-node/src/services/RpgWorldPreviewCompiler.fixture.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createRpgAgentFoundationDraftProfileFixture, + createRpgCreationPublishedProfileFixture, +} from '../../../packages/shared/src/contracts/rpgCreationFixtures.js'; +import { + buildRpgWorldPreviewEnvelope, + normalizeRpgWorldPreviewEnvelope, +} from './RpgWorldPreviewCompiler.js'; + +test('rpg world preview compiler can consume shared published profile fixture as a stable unit baseline', () => { + const publishedProfile = createRpgCreationPublishedProfileFixture(); + const previewEnvelope = buildRpgWorldPreviewEnvelope( + publishedProfile, + String(publishedProfile.settingText ?? ''), + ); + + assert.equal(previewEnvelope.source, 'session_preview'); + assert.equal(previewEnvelope.preview.name, publishedProfile.name); + assert.equal( + (previewEnvelope.preview.playableNpcs as Array<{ generatedAnimationSetId?: string }>)[0] + ?.generatedAnimationSetId, + 'animation-set-playable-1', + ); + assert.equal( + ( + previewEnvelope.preview.sceneChapterBlueprints as Array<{ + acts?: Array<{ backgroundImageSrc?: string }>; + }> + )[0]?.acts?.[0]?.backgroundImageSrc, + '/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png', + ); +}); + +test('regression: foundation-like shared fixture fields are preserved after normalize + preview compile chain', () => { + const foundationDraft = createRpgAgentFoundationDraftProfileFixture(); + const normalizedPreviewEnvelope = normalizeRpgWorldPreviewEnvelope( + { + name: foundationDraft.name, + subtitle: foundationDraft.subtitle, + summary: foundationDraft.summary, + tone: foundationDraft.tone, + playerGoal: foundationDraft.playerGoal, + templateWorldType: 'WUXIA', + majorFactions: foundationDraft.majorFactions, + coreConflicts: foundationDraft.coreConflicts, + playableNpcs: foundationDraft.playableNpcs, + storyNpcs: foundationDraft.storyNpcs, + camp: foundationDraft.camp, + landmarks: foundationDraft.landmarks, + sceneChapterBlueprints: foundationDraft.sceneChapters, + themePack: foundationDraft.themePack, + storyGraph: foundationDraft.storyGraph, + }, + foundationDraft.worldHook, + ); + + assert.equal(normalizedPreviewEnvelope.source, 'session_preview'); + assert.equal( + (normalizedPreviewEnvelope.preview.playableNpcs as Array<{ imageSrc?: string }>)[0] + ?.imageSrc, + '/generated-characters/playable-1/visual/asset-runtime/master.png', + ); + assert.equal( + (normalizedPreviewEnvelope.preview.playableNpcs as Array<{ + animationMap?: { attack?: { basePath?: string } }; + }>)[0]?.animationMap?.attack?.basePath, + '/generated-characters/playable-1/animations/attack', + ); + assert.equal( + ( + normalizedPreviewEnvelope.preview.sceneChapterBlueprints as Array<{ + acts?: Array<{ backgroundAssetId?: string }>; + }> + )[0]?.acts?.[0]?.backgroundAssetId, + 'scene-asset-runtime', + ); +}); diff --git a/server-node/src/services/RpgWorldPreviewCompiler.test.ts b/server-node/src/services/RpgWorldPreviewCompiler.test.ts new file mode 100644 index 00000000..f4899792 --- /dev/null +++ b/server-node/src/services/RpgWorldPreviewCompiler.test.ts @@ -0,0 +1,269 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildRpgWorldPreviewEnvelope, + buildRpgWorldPreviewProfile, + normalizeRpgWorldPreviewEnvelope, +} from './RpgWorldPreviewCompiler.js'; +import { buildRpgCreationPreviewProfileFromDraftProfile } from './rpgCreationPreviewProfileBuilder.js'; + +function createPreviewFixture() { + const storyNpcs = Array.from({ length: 25 }, (_, index) => ({ + name: `场景角色${index + 1}`, + title: `头衔${index + 1}`, + role: `职责${index + 1}`, + description: `场景角色描述${index + 1}`, + backstory: `场景角色背景${index + 1}`, + personality: `场景角色性格${index + 1}`, + motivation: `场景角色动机${index + 1}`, + combatStyle: `场景角色战斗风格${index + 1}`, + initialAffinity: index % 4 === 0 ? -10 : 6, + relationshipHooks: [`关系${index + 1}`], + tags: [`线索${index + 1}`], + })); + + return { + id: 'preview-world', + name: '预览测试世界', + subtitle: '预览副标题', + summary: '服务端预览编译的兼容结果。', + tone: '压抑、潮湿', + playerGoal: '先确认谁在推动局势,再决定站位。', + templateWorldType: 'WUXIA', + majorFactions: ['守灯会', '潮线商盟'], + coreConflicts: ['旧航道解释权正在被重写'], + playableNpcs: Array.from({ length: 5 }, (_, index) => ({ + name: `角色${index + 1}`, + title: `称号${index + 1}`, + role: `身份${index + 1}`, + description: `角色描述${index + 1}`, + backstory: `角色背景${index + 1}`, + personality: `角色性格${index + 1}`, + motivation: `角色动机${index + 1}`, + combatStyle: `战斗风格${index + 1}`, + initialAffinity: 18, + relationshipHooks: [`接触点${index + 1}`], + tags: [`标签${index + 1}`], + })), + storyNpcs, + landmarks: Array.from({ length: 10 }, (_, index) => ({ + name: `场景${index + 1}`, + description: `场景描述${index + 1}`, + dangerLevel: 'medium', + sceneNpcNames: [ + storyNpcs[index % storyNpcs.length]?.name ?? `场景角色${index + 1}`, + storyNpcs[(index + 1) % storyNpcs.length]?.name ?? + `场景角色${index + 2}`, + storyNpcs[(index + 2) % storyNpcs.length]?.name ?? + `场景角色${index + 3}`, + ], + connections: [ + { + targetLandmarkName: `场景${((index + 1) % 10) + 1}`, + relativePosition: 'forward', + summary: '沿主路前行', + }, + { + targetLandmarkName: `场景${((index + 9) % 10) + 1}`, + relativePosition: 'back', + summary: '回身可返', + }, + ], + })), + }; +} + +test('rpg world preview compiler builds a legacy-compatible preview envelope on the server', () => { + const settingText = '一个被潮雾反复切开的边境世界。'; + const rawProfile = createPreviewFixture(); + + const previewProfile = buildRpgWorldPreviewProfile(rawProfile, settingText); + const previewEnvelope = buildRpgWorldPreviewEnvelope(rawProfile, settingText); + const normalizedEnvelope = normalizeRpgWorldPreviewEnvelope( + rawProfile, + settingText, + ); + + assert.equal(previewProfile.name, '预览测试世界'); + assert.equal(previewProfile.playableNpcs.length, 5); + assert.equal(previewEnvelope.source, 'session_preview'); + assert.equal(normalizedEnvelope.source, 'session_preview'); + assert.equal(previewEnvelope.preview.name, '预览测试世界'); + assert.equal(previewEnvelope.preview.scenarioPackId, 'scenario-pack:预览测试世界'); + assert.equal( + normalizedEnvelope.preview.campaignPackId, + 'campaign-pack:预览测试世界', + ); +}); + +test('phase5 preview builder keeps legacy runtime-rich fields while merging latest draft assets', () => { + const previewProfile = buildRpgCreationPreviewProfileFromDraftProfile({ + sessionId: 'session-phase5-preview', + draftProfile: { + name: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '第一版世界底稿已经整理完成。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清沉船与禁航区异动的真相。', + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + playableNpcs: [ + { + id: 'playable-1', + name: '沈砺', + imageSrc: '/generated-characters/playable-1/visual/asset-runtime/master.png', + generatedVisualAssetId: 'asset-runtime-playable', + generatedAnimationSetId: 'animation-set-runtime-playable', + animationMap: { + attack: { + basePath: '/generated-characters/playable-1/animations/attack', + }, + }, + }, + ], + storyNpcs: [ + { + id: 'story-1', + name: '顾潮音', + imageSrc: '/generated-characters/story-1/visual/asset-runtime/master.png', + generatedVisualAssetId: 'asset-runtime-story', + }, + ], + landmarks: [ + { + id: 'landmark-1', + name: '回潮旧灯塔', + imageSrc: '/generated-custom-world-scenes/landmark-1/scene.png', + generatedSceneAssetId: 'scene-asset-runtime', + }, + ], + sceneChapters: [ + { + id: 'scene-chapter-1', + sceneId: 'landmark-1', + title: '灯塔初章', + acts: [ + { + id: 'scene-act-1', + title: '第一幕', + backgroundImageSrc: + '/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png', + backgroundAssetId: 'scene-act-runtime', + }, + ], + }, + ], + legacyResultProfile: { + id: 'agent-draft-session-phase5-preview', + settingText: '被海雾吞没的旧航路群岛', + name: '潮雾列岛·结果页精修版', + subtitle: '旧灯塔与失控航路', + summary: '服务端 preview 需要保留结果页富字段。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清沉船夜与假航灯的真正操盘者。', + templateWorldType: 'WUXIA', + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + playableNpcs: [ + { + id: 'playable-1', + name: '沈砺', + title: '旧航路引路人', + role: '关键同行者', + description: '最熟悉旧航路的人。', + backstory: '曾在沉船夜里带着半支船队逃出海雾。', + personality: '表面沉稳,心里一直在算退路。', + motivation: '想赶在守灯会封航前查清真相。', + combatStyle: '借地形和潮路换位,先拉扯再压近。', + initialAffinity: 18, + relationshipHooks: ['旧友', '沉船旧案'], + tags: ['潮路', '引路'], + narrativeProfile: { + publicMask: '像个只想把旧路再走通一次的熟路人。', + }, + }, + ], + storyNpcs: [ + { + id: 'story-1', + name: '顾潮音', + title: '守灯会值夜人', + role: '场景关键角色', + description: '夜里巡灯与封锁禁航区的人。', + backstory: '在失控海雾第一次吞船那夜,她是最后一个还留在灯塔顶的人。', + personality: '冷静克制,但提到旧灯册时会显得过分警觉。', + motivation: '想守住灯塔记录,也想找到谁先改动了禁航信号。', + combatStyle: '借塔顶视角和风向压制,再用灯火错位扰乱。', + initialAffinity: 8, + relationshipHooks: ['禁航记录', '灯塔值夜'], + tags: ['守灯会', '灯塔'], + }, + ], + items: [ + { + id: 'item-world-1', + name: '潮雾罗盘', + category: '饰品', + }, + ], + landmarks: [ + { + id: 'landmark-1', + name: '回潮旧灯塔', + description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。', + dangerLevel: 'high', + sceneNpcIds: ['story-1'], + connections: [], + }, + ], + themePack: { + id: 'theme-pack:tide', + }, + knowledgeFacts: [ + { + id: 'fact-1', + title: '高处潮痕', + }, + ], + threadContracts: [ + { + id: 'contract-1', + threadId: 'thread-visible-1', + }, + ], + sceneChapterBlueprints: [ + { + id: 'scene-chapter-1', + sceneId: 'landmark-1', + title: '灯塔初章', + acts: [ + { + id: 'scene-act-1', + title: '第一幕', + }, + ], + }, + ], + generationMode: 'full', + generationStatus: 'complete', + }, + }, + }); + + assert.equal(previewProfile.name, '潮雾列岛'); + assert.equal(previewProfile.playerGoal, '查清沉船与禁航区异动的真相。'); + assert.equal(previewProfile.themePack?.id, 'theme-pack:tide'); + assert.equal(previewProfile.knowledgeFacts?.[0]?.id, 'fact-1'); + assert.equal(previewProfile.threadContracts?.[0]?.id, 'contract-1'); + assert.equal(previewProfile.playableNpcs[0]?.imageSrc, '/generated-characters/playable-1/visual/asset-runtime/master.png'); + assert.equal(previewProfile.playableNpcs[0]?.generatedAnimationSetId, 'animation-set-runtime-playable'); + assert.equal( + previewProfile.playableNpcs[0]?.narrativeProfile?.publicMask, + '像个只想把旧路再走通一次的熟路人。', + ); + assert.equal( + previewProfile.sceneChapterBlueprints?.[0]?.acts?.[0]?.backgroundAssetId, + 'scene-act-runtime', + ); +}); diff --git a/server-node/src/services/RpgWorldPreviewCompiler.ts b/server-node/src/services/RpgWorldPreviewCompiler.ts index 793010f4..483b488b 100644 --- a/server-node/src/services/RpgWorldPreviewCompiler.ts +++ b/server-node/src/services/RpgWorldPreviewCompiler.ts @@ -1,14 +1,65 @@ import { buildCompiledCustomWorldProfile, normalizeCustomWorldProfile, -} from '../modules/custom-world/runtimeProfile.js'; +} from '../modules/custom-world/runtime-profile/index.js'; +import type { + RpgCreationPreview, + RpgCreationPreviewEnvelope, + RpgCreationPreviewSource, +} from '../../../packages/shared/src/contracts/rpgCreationPreview.js'; import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js'; /** - * 工作包 A 先建立 RPG 世界预览编译器的新命名 façade。 - * 当前仍桥接旧 runtime profile 编译能力,后续工作包 G 会把正式 preview compiler 收口到这个入口。 + * 工作包 G 把服务端结果预览编译入口收口到这里。 + * Phase 5 后当前 preview 正式作为 session_preview 主链输出, + * 编译边界已经从 foundation draft 流程中抽离。 */ export type RpgWorldPreviewProfile = CustomWorldProfile; -export const buildRpgWorldPreviewProfile = buildCompiledCustomWorldProfile; -export const normalizeRpgWorldPreviewProfile = normalizeCustomWorldProfile; +const RPG_WORLD_PREVIEW_SOURCE: RpgCreationPreviewSource = + 'session_preview'; + +function toRpgCreationPreview( + profile: RpgWorldPreviewProfile, +): RpgCreationPreview { + return profile as unknown as RpgCreationPreview; +} + +export function buildRpgWorldPreviewProfile( + raw: unknown, + settingText: string, +): RpgWorldPreviewProfile { + return buildCompiledCustomWorldProfile(raw, settingText); +} + +export function normalizeRpgWorldPreviewProfile( + raw: unknown, + settingText: string, +): RpgWorldPreviewProfile { + return normalizeCustomWorldProfile(raw, settingText); +} + +export function buildRpgWorldPreviewEnvelope( + raw: unknown, + settingText: string, +): RpgCreationPreviewEnvelope { + return { + preview: toRpgCreationPreview(buildRpgWorldPreviewProfile(raw, settingText)), + source: RPG_WORLD_PREVIEW_SOURCE, + }; +} + +export function normalizeRpgWorldPreviewEnvelope( + raw: unknown, + settingText: string, +): RpgCreationPreviewEnvelope { + return { + preview: toRpgCreationPreview( + buildRpgWorldPreviewProfile( + normalizeRpgWorldPreviewProfile(raw, settingText), + settingText, + ), + ), + source: RPG_WORLD_PREVIEW_SOURCE, + }; +} diff --git a/server-node/src/services/RpgWorldWorkCoverResolver.ts b/server-node/src/services/RpgWorldWorkCoverResolver.ts new file mode 100644 index 00000000..186825cf --- /dev/null +++ b/server-node/src/services/RpgWorldWorkCoverResolver.ts @@ -0,0 +1,46 @@ +import type { + CustomWorldLibraryEntry, + CustomWorldProfileRecord, +} from '../../../packages/shared/src/contracts/runtime.js'; +import { resolveCustomWorldCoverPresentation } from '../repositories/customWorldLibraryMetadata.js'; +import type { CustomWorldAgentSessionRecord } from './customWorldAgentSessionStore.js'; + +function toRecord(value: unknown) { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** + * 作品封面解析统一收口在这里,避免 works 聚合服务重复理解草稿态与发布态的封面规则。 + */ +export function resolveRpgWorldDraftWorkCover( + session: CustomWorldAgentSessionRecord, +) { + const draftProfile = toRecord(session.draftProfile); + if (!draftProfile) { + return { + imageSrc: null, + renderMode: 'image' as const, + characterImageSrcs: [], + }; + } + + return resolveCustomWorldCoverPresentation( + draftProfile as CustomWorldProfileRecord, + ); +} + +export function resolveRpgWorldPublishedWorkCover( + libraryEntry: CustomWorldLibraryEntry, +) { + const coverPresentation = resolveCustomWorldCoverPresentation( + libraryEntry.profile, + ); + + return { + imageSrc: libraryEntry.coverImageSrc || coverPresentation.imageSrc, + renderMode: coverPresentation.renderMode, + characterImageSrcs: coverPresentation.characterImageSrcs, + }; +} diff --git a/server-node/src/services/RpgWorldWorkSummaryAssembler.fixture.test.ts b/server-node/src/services/RpgWorldWorkSummaryAssembler.fixture.test.ts new file mode 100644 index 00000000..9aeb0771 --- /dev/null +++ b/server-node/src/services/RpgWorldWorkSummaryAssembler.fixture.test.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createRpgAgentSessionFixture, + createRpgCreationWorksResponseFixture, + createRpgWorldLibraryEntryFixture, +} from '../../../packages/shared/src/contracts/rpgCreationFixtures.js'; +import { RpgWorldWorkSummaryAssembler } from './RpgWorldWorkSummaryAssembler.js'; + +test('rpg world work summary assembler can consume shared fixture baselines as a unit test', () => { + const assembler = new RpgWorldWorkSummaryAssembler(); + const session = createRpgAgentSessionFixture(); + const libraryEntry = createRpgWorldLibraryEntryFixture(); + const [draftItem] = assembler.assembleDraftItems([ + { + ...JSON.parse(JSON.stringify(session)), + userId: 'fixture-user', + seedText: '被海雾吞没的旧航路群岛', + operations: [], + checkpoints: [], + createdAt: session.updatedAt, + }, + ]); + const [publishedItem] = assembler.assemblePublishedItems([libraryEntry]); + + assert.equal(draftItem.sourceType, 'agent_session'); + assert.equal(draftItem.roleVisualReadyCount, 2); + assert.equal(draftItem.roleAnimationReadyCount, 2); + assert.equal(draftItem.roleAssetSummaryLabel, '沈砺 · 动作已就绪'); + assert.equal(draftItem.canEnterWorld, false); + assert.equal(draftItem.publishReady, true); + assert.equal(draftItem.blockerCount, 0); + assert.equal(publishedItem.sourceType, 'published_profile'); + assert.equal(publishedItem.canEnterWorld, true); + assert.equal(publishedItem.publishReady, true); + assert.equal(publishedItem.blockerCount, 0); + assert.equal(publishedItem.roleAnimationReadyCount, 1); +}); + +test('regression: assembler output stays aligned with shared works response fixture', () => { + const assembler = new RpgWorldWorkSummaryAssembler(); + const session = createRpgAgentSessionFixture(); + const libraryEntry = createRpgWorldLibraryEntryFixture(); + const expected = createRpgCreationWorksResponseFixture(); + + const [draftItem] = assembler.assembleDraftItems([ + { + ...JSON.parse(JSON.stringify(session)), + userId: 'fixture-user', + seedText: '被海雾吞没的旧航路群岛', + operations: [], + checkpoints: [], + createdAt: session.updatedAt, + }, + ]); + const [publishedItem] = assembler.assemblePublishedItems([libraryEntry]); + const expectedDraft = expected.items.find((entry) => entry.sourceType === 'agent_session'); + const expectedPublished = expected.items.find( + (entry) => entry.sourceType === 'published_profile', + ); + + assert.ok(expectedDraft); + assert.ok(expectedPublished); + assert.equal(draftItem.coverImageSrc, expectedDraft.coverImageSrc); + assert.deepEqual( + draftItem.coverCharacterImageSrcs, + expectedDraft.coverCharacterImageSrcs, + ); + assert.equal(draftItem.stageLabel, expectedDraft.stageLabel); + assert.equal(draftItem.publishReady, expectedDraft.publishReady); + assert.equal(draftItem.blockerCount, expectedDraft.blockerCount); + assert.equal(publishedItem.coverImageSrc, expectedPublished.coverImageSrc); + assert.equal( + publishedItem.roleAssetSummaryLabel, + expectedPublished.roleAssetSummaryLabel, + ); +}); + +test('published sessions do not leak back into draft work summaries', () => { + const assembler = new RpgWorldWorkSummaryAssembler(); + const session = createRpgAgentSessionFixture(); + const draftItems = assembler.assembleDraftItems([ + { + ...JSON.parse(JSON.stringify(session)), + userId: 'fixture-user', + seedText: '被海雾吞没的旧航路群岛', + operations: [], + checkpoints: [], + createdAt: session.updatedAt, + stage: 'published', + }, + ]); + + assert.equal(draftItems.length, 0); +}); diff --git a/server-node/src/services/RpgWorldWorkSummaryAssembler.ts b/server-node/src/services/RpgWorldWorkSummaryAssembler.ts new file mode 100644 index 00000000..7942c629 --- /dev/null +++ b/server-node/src/services/RpgWorldWorkSummaryAssembler.ts @@ -0,0 +1,301 @@ +import type { + CustomWorldAgentStage, + CustomWorldWorkSummary, +} from '../../../packages/shared/src/contracts/customWorldAgent.js'; +import type { + CustomWorldLibraryEntry, + CustomWorldProfileRecord, +} from '../../../packages/shared/src/contracts/runtime.js'; +import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; +import { + buildDraftSummaryFromIntent, + buildDraftTitleFromIntent, + normalizeCreatorIntentRecord, +} from './customWorldAgentIntentExtractionService.js'; +import { + rebuildRoleAssetCoverage, + resolveRoleAssetStatusLabel, +} from './customWorldAgentRoleAssetStateService.js'; +import type { CustomWorldAgentSessionRecord } from './customWorldAgentSessionStore.js'; +import { + buildDraftSummaryFromEightAnchorContent, + buildDraftTitleFromEightAnchorContent, +} from './eightAnchorCompatibilityService.js'; +import { + resolveRpgWorldDraftWorkCover, + resolveRpgWorldPublishedWorkCover, +} from './RpgWorldWorkCoverResolver.js'; +import { CustomWorldAgentPublishingService } from './customWorldAgentPublishingService.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) + : null; +} + +function toRecordArray(value: unknown) { + return Array.isArray(value) + ? value.filter((item) => item && typeof item === 'object') + : []; +} + +function truncateText(value: string, maxLength: number) { + if (value.length <= maxLength) { + return value; + } + + return `${value.slice(0, Math.max(0, maxLength - 1)).trim()}…`; +} + +function formatDraftStageLabel(stage: CustomWorldAgentStage) { + if (stage === 'collecting_intent') return '收集世界锚点'; + if (stage === 'clarifying') return '补齐关键锚点'; + if (stage === 'foundation_review') return '准备整理底稿'; + if (stage === 'object_refining') return '待完善草稿'; + if (stage === 'visual_refining') return '视觉工坊'; + if (stage === 'long_tail_review') return '扩展长尾'; + if (stage === 'ready_to_publish') return '准备发布'; + if (stage === 'published') return '已发布'; + return '发生错误'; +} + +function resolveDraftTitle(session: CustomWorldAgentSessionRecord) { + const intent = normalizeCreatorIntentRecord(session.creatorIntent); + const draftProfile = normalizeFoundationDraftProfile(session.draftProfile); + + return ( + draftProfile?.name || + buildDraftTitleFromEightAnchorContent(session.anchorContent) || + buildDraftTitleFromIntent(intent) || + toText(session.draftProfile?.title) || + truncateText(session.seedText, 18) || + '未命名草稿' + ); +} + +function resolveDraftSummary(session: CustomWorldAgentSessionRecord) { + const intent = normalizeCreatorIntentRecord(session.creatorIntent); + const compiledSummary = buildDraftSummaryFromIntent(intent); + const draftProfile = normalizeFoundationDraftProfile(session.draftProfile); + + return ( + draftProfile?.summary || + buildDraftSummaryFromEightAnchorContent(session.anchorContent) || + compiledSummary || + toText(session.draftProfile?.summary) || + truncateText(session.seedText, 72) || + '还在收集你的世界锚点。' + ); +} + +function resolveDraftCounts(session: CustomWorldAgentSessionRecord) { + const draftProfile = normalizeFoundationDraftProfile(session.draftProfile); + if (draftProfile) { + // 草稿作品卡需要展示当前可编辑的全部角色数量,而不是仅统计可扮演角色。 + const totalRoleCount = [ + ...new Set( + [...draftProfile.playableNpcs, ...draftProfile.storyNpcs].map( + (entry) => entry.id, + ), + ), + ].length; + + return { + playableNpcCount: totalRoleCount, + landmarkCount: draftProfile.landmarks.length, + }; + } + + const playableNpcCount = session.draftCards.filter( + (card) => card.kind === 'character', + ).length; + const landmarkCount = session.draftCards.filter( + (card) => card.kind === 'landmark' || card.kind === 'camp', + ).length; + + return { + playableNpcCount, + landmarkCount, + }; +} + +function resolveDraftRoleAssetProgress(session: CustomWorldAgentSessionRecord) { + const coverage = rebuildRoleAssetCoverage(session.draftProfile); + const roleVisualReadyCount = coverage.roleAssets.filter( + (entry) => entry.status !== 'missing', + ).length; + const roleAnimationReadyCount = coverage.roleAssets.filter( + (entry) => entry.status === 'complete', + ).length; + const leadRole = coverage.roleAssets[0]; + + return { + roleVisualReadyCount, + roleAnimationReadyCount, + roleAssetSummaryLabel: leadRole + ? `${leadRole.roleName} · ${resolveRoleAssetStatusLabel(leadRole.status)}` + : coverage.roleAssets.length > 0 + ? '角色资产进行中' + : null, + }; +} + +function isLibraryEntry( + value: unknown, +): value is CustomWorldLibraryEntry { + const record = toRecord(value); + return ( + record !== null && + typeof record.ownerUserId === 'string' && + typeof record.profileId === 'string' && + Boolean(toRecord(record.profile)) + ); +} + +function isPublishedLibraryEntry( + value: unknown, +): value is CustomWorldLibraryEntry { + return isLibraryEntry(value) && value.visibility === 'published'; +} + +/** + * works 组装器只负责把 session/profile 转成稳定读模型,不直接发起仓储读取。 + */ +export class RpgWorldWorkSummaryAssembler { + private readonly publishGateService = new CustomWorldAgentPublishingService({ + listOwnProfiles: async () => [], + upsertOwnProfile: async () => { + throw new Error('publish repository is unavailable in work summary assembler'); + }, + syncProfileFromSnapshot: async () => undefined, + softDeleteOwnProfile: async () => [], + publishOwnProfile: async () => null, + unpublishOwnProfile: async () => null, + listPublishedGallery: async () => [], + getPublishedGalleryDetail: async () => null, + }); + + assembleDraftItems(sessions: CustomWorldAgentSessionRecord[]) { + return sessions + .filter((session) => session.stage !== 'published') + .map((session) => { + const counts = resolveDraftCounts(session); + const roleAssetProgress = resolveDraftRoleAssetProgress(session); + const coverPresentation = resolveRpgWorldDraftWorkCover(session); + const publishState = this.publishGateService.summarizePublishGate({ + sessionId: session.sessionId, + stage: session.stage, + draftProfile: session.draftProfile, + qualityFindings: session.qualityFindings, + }); + + return { + workId: `draft:${session.sessionId}`, + sourceType: 'agent_session', + status: 'draft', + title: resolveDraftTitle(session), + subtitle: + normalizeFoundationDraftProfile(session.draftProfile)?.subtitle || + formatDraftStageLabel(session.stage), + summary: resolveDraftSummary(session), + coverImageSrc: coverPresentation.imageSrc, + coverRenderMode: coverPresentation.renderMode, + coverCharacterImageSrcs: coverPresentation.characterImageSrcs, + updatedAt: session.updatedAt, + publishedAt: null, + stage: session.stage, + stageLabel: formatDraftStageLabel(session.stage), + playableNpcCount: counts.playableNpcCount, + landmarkCount: counts.landmarkCount, + roleVisualReadyCount: roleAssetProgress.roleVisualReadyCount, + roleAnimationReadyCount: roleAssetProgress.roleAnimationReadyCount, + roleAssetSummaryLabel: roleAssetProgress.roleAssetSummaryLabel, + sessionId: session.sessionId, + profileId: null, + canResume: true, + canEnterWorld: publishState.canEnterWorld, + blockerCount: publishState.blockerCount, + publishReady: publishState.publishReady, + } satisfies CustomWorldWorkSummary; + }); + } + + assemblePublishedItems( + profiles: Array>, + ) { + return profiles.filter(isPublishedLibraryEntry).map((libraryEntry) => { + const profileRecord = libraryEntry.profile as CustomWorldProfileRecord & + Record; + const playableNpcs = toRecordArray(profileRecord.playableNpcs); + const landmarks = toRecordArray(profileRecord.landmarks); + const updatedAt = + toText(libraryEntry.updatedAt) || + toText(profileRecord.updatedAt) || + new Date().toISOString(); + const coverPresentation = resolveRpgWorldPublishedWorkCover(libraryEntry); + const roleVisualReadyCount = playableNpcs.filter( + (entry) => + Boolean(toText(entry.imageSrc)) && + Boolean(toText(entry.generatedVisualAssetId)), + ).length; + const roleAnimationReadyCount = playableNpcs.filter((entry) => + Boolean(toText(entry.generatedAnimationSetId)), + ).length; + + return { + workId: `published:${toText(profileRecord.id) || updatedAt}`, + sourceType: 'published_profile', + status: 'published', + title: + toText(libraryEntry.worldName) || + toText(profileRecord.name) || + '未命名世界', + subtitle: + toText(libraryEntry.subtitle) || + toText(profileRecord.subtitle) || + '已保存作品', + summary: + toText(libraryEntry.summaryText) || + toText(profileRecord.summary) || + '这个世界已经可以直接进入体验。', + coverImageSrc: coverPresentation.imageSrc, + coverRenderMode: coverPresentation.renderMode, + coverCharacterImageSrcs: coverPresentation.characterImageSrcs, + updatedAt, + publishedAt: + toText(libraryEntry.publishedAt) || + toText(profileRecord.publishedAt) || + updatedAt, + stage: 'published', + stageLabel: '已发布', + playableNpcCount: + libraryEntry.playableNpcCount > 0 + ? libraryEntry.playableNpcCount + : playableNpcs.length, + landmarkCount: + libraryEntry.landmarkCount > 0 + ? libraryEntry.landmarkCount + : landmarks.length, + roleVisualReadyCount, + roleAnimationReadyCount, + roleAssetSummaryLabel: + roleAnimationReadyCount > 0 + ? `动作已就绪 ${roleAnimationReadyCount}` + : roleVisualReadyCount > 0 + ? `主图已就绪 ${roleVisualReadyCount}` + : null, + sessionId: null, + profileId: + toText(libraryEntry.profileId) || toText(profileRecord.id) || null, + canResume: false, + canEnterWorld: true, + blockerCount: 0, + publishReady: true, + } satisfies CustomWorldWorkSummary; + }); + } +} diff --git a/server-node/src/services/RpgWorldWorkSummaryService.ts b/server-node/src/services/RpgWorldWorkSummaryService.ts index 7efe931b..06cd2df1 100644 --- a/server-node/src/services/RpgWorldWorkSummaryService.ts +++ b/server-node/src/services/RpgWorldWorkSummaryService.ts @@ -1 +1,44 @@ -export { listCustomWorldWorkSummaries as listRpgWorldWorkSummaries } from './customWorldWorkSummaryService.js'; +import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent.js'; +import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js'; +import type { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; +import { RpgWorldWorkSummaryAssembler } from './RpgWorldWorkSummaryAssembler.js'; + +/** + * RPG 作品卡服务只负责组织“草稿 session + 已发布作品”两类读模型, + * 不再直接承担读库 SQL 或封面字段推导细节。 + */ +export class RpgWorldWorkSummaryService { + private readonly assembler: RpgWorldWorkSummaryAssembler; + + constructor( + private readonly rpgWorldProfiles: RpgWorldProfileRepositoryPort, + private readonly customWorldAgentSessions: CustomWorldAgentSessionStore, + assembler: RpgWorldWorkSummaryAssembler = new RpgWorldWorkSummaryAssembler(), + ) { + this.assembler = assembler; + } + + async list(userId: string): Promise { + const [sessions, profiles] = await Promise.all([ + this.customWorldAgentSessions.list(userId), + this.rpgWorldProfiles.listOwnProfiles(userId), + ]); + + const draftItems = this.assembler.assembleDraftItems(sessions); + const publishedItems = this.assembler.assemblePublishedItems(profiles); + + return [...draftItems, ...publishedItems].sort((left, right) => { + const updatedAtDiff = + new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(); + if (updatedAtDiff !== 0) { + return updatedAtDiff; + } + + if (left.sourceType !== right.sourceType) { + return left.sourceType === 'agent_session' ? -1 : 1; + } + + return left.workId.localeCompare(right.workId); + }); + } +} diff --git a/server-node/src/services/chatService.ts b/server-node/src/services/chatService.ts index cc7a35a7..4242155e 100644 --- a/server-node/src/services/chatService.ts +++ b/server-node/src/services/chatService.ts @@ -7,7 +7,7 @@ import type { NpcChatDialogueRequest, NpcChatTurnRequest, NpcRecruitDialogueRequest, -} from '../../../packages/shared/src/contracts/story.js'; +} from '../../../packages/shared/src/contracts/rpgRuntimeChat.js'; const jsonObjectSchema = z.record(z.string(), z.unknown()); diff --git a/server-node/src/services/customWorldAgentActionExecutors/draftFoundationExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/draftFoundationExecutor.ts new file mode 100644 index 00000000..9cfd0031 --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/draftFoundationExecutor.ts @@ -0,0 +1,145 @@ +import { + buildCreatorIntentFromEightAnchorContent, + buildAnchorPackFromEightAnchorContent, +} from '../eightAnchorCompatibilityService.js'; +import { rebuildRoleAssetCoverage } from '../customWorldAgentRoleAssetStateService.js'; +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentFoundationDraftService } from '../customWorldAgentFoundationDraftService.js'; +import type { CustomWorldAgentAutoAssetService } from '../customWorldAgentAutoAssetService.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { buildFoundationDraftAssistantMessage } from './helpers.js'; +import { + buildCheckpointSnapshot, + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +export function createDraftFoundationExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + foundationDraftService: CustomWorldAgentFoundationDraftService; + autoAssetService: CustomWorldAgentAutoAssetService | null; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; +}): CustomWorldAgentActionExecutor<'draft_foundation'> { + return async ({ userId, sessionId, operationId }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '整理世界骨架', + phaseDetail: '正在校验已确认锚点,并准备第一版世界框架生成链路。', + progress: 12, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + if (latestSession.progressPercent < 100) { + throw new Error('session progressPercent is below 100'); + } + + const creatorIntent = buildCreatorIntentFromEightAnchorContent( + latestSession.anchorContent, + ); + const anchorPack = buildAnchorPackFromEightAnchorContent( + latestSession.anchorContent, + latestSession.progressPercent, + ); + const draftProfile = await params.foundationDraftService.generate({ + creatorIntent, + anchorPack, + anchorContent: latestSession.anchorContent, + onProgress: async (progress) => { + await updateOperation({ + status: 'running', + phaseLabel: progress.phaseLabel, + phaseDetail: progress.phaseDetail, + progress: progress.progress, + }); + }, + }); + + const draftWithAssets = params.autoAssetService + ? await params.autoAssetService.populateDraftAssets({ + draftProfile, + onProgress: async (progress) => { + await updateOperation({ + status: 'running', + phaseLabel: progress.phaseLabel, + phaseDetail: progress.phaseDetail, + progress: progress.progress, + }); + }, + }) + : { + draftProfile, + assetCoverage: rebuildRoleAssetCoverage(draftProfile), + warnings: [], + }; + + await updateOperation({ + phaseLabel: '编译草稿卡', + phaseDetail: '正在把世界底稿整理成可浏览的卡片摘要和详情结构。', + progress: 98, + }); + + const nextState = params.snapshotBuilder.buildFoundationDraftState({ + creatorIntent, + anchorPack, + draftProfile: + draftWithAssets.draftProfile as unknown as Record, + assetCoverage: draftWithAssets.assetCoverage, + }); + + await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); + await params.sessionStore.appendCheckpoint(userId, sessionId, { + label: '世界底稿 V1', + snapshot: buildCheckpointSnapshot(latestSession, nextState), + }); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildFoundationDraftAssistantMessage({ + relatedOperationId: operationId, + draftProfile: draftWithAssets.draftProfile, + warnings: draftWithAssets.warnings, + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '世界底稿已生成', + phaseDetail: + draftWithAssets.warnings.length > 0 + ? `第一版世界底稿和 ${(nextState.draftCards ?? []).length} 张草稿卡已经整理完成,另有 ${draftWithAssets.warnings.length} 项资产补齐待后续处理。` + : `第一版世界底稿和 ${(nextState.draftCards ?? []).length} 张草稿卡已经整理完成。`, + progress: 100, + error: null, + }); + } catch (error) { + const currentOperation = await params.sessionStore.getOperation( + userId, + sessionId, + operationId, + ); + await updateOperation({ + status: 'failed', + phaseLabel: currentOperation?.phaseLabel?.trim() || '底稿生成失败', + phaseDetail: + currentOperation?.phaseDetail?.trim() || + '这一轮没有成功把设定编成世界底稿。', + progress: 100, + error: + error instanceof Error ? error.message : 'draft foundation failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/executorShared.ts b/server-node/src/services/customWorldAgentActionExecutors/executorShared.ts new file mode 100644 index 00000000..6c046b2c --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/executorShared.ts @@ -0,0 +1,108 @@ +import type { CustomWorldAgentOperationRecord } from '../../../../packages/shared/src/contracts/customWorldAgent.js'; +import type { CustomWorldAgentSessionRecord, CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; + +export type UpdateExecutorOperation = ( + patch: Partial, +) => Promise; + +export async function getRequiredSession(params: { + sessionStore: CustomWorldAgentSessionStore; + userId: string; + sessionId: string; +}) { + const session = (await params.sessionStore.get( + params.userId, + params.sessionId, + )) as CustomWorldAgentSessionRecord | null; + if (!session) { + throw new Error('custom world agent session not found'); + } + + return session; +} + +export function createOperationUpdater(params: { + sessionStore: CustomWorldAgentSessionStore; + userId: string; + sessionId: string; + operationId: string; +}): UpdateExecutorOperation { + return (patch) => + params.sessionStore.updateOperation( + params.userId, + params.sessionId, + params.operationId, + patch, + ); +} + +// checkpoint 恢复依赖这份最小可回放快照,统一由 executor 共享,避免每个动作手写字段集合。 +export function buildCheckpointSnapshot( + session: CustomWorldAgentSessionRecord, + patch: Partial< + Pick< + CustomWorldAgentSessionRecord, + | 'currentTurn' + | 'anchorContent' + | 'progressPercent' + | 'lastAssistantReply' + | 'stage' + | 'focusCardId' + | 'creatorIntent' + | 'creatorIntentReadiness' + | 'anchorPack' + | 'lockState' + | 'draftProfile' + | 'pendingClarifications' + | 'suggestedActions' + | 'recommendedReplies' + | 'draftCards' + | 'qualityFindings' + | 'assetCoverage' + > + >, +) { + return { + currentTurn: patch.currentTurn ?? session.currentTurn, + anchorContent: patch.anchorContent ?? session.anchorContent, + progressPercent: patch.progressPercent ?? session.progressPercent, + lastAssistantReply: + patch.lastAssistantReply !== undefined + ? patch.lastAssistantReply + : session.lastAssistantReply, + stage: patch.stage ?? session.stage, + focusCardId: + patch.focusCardId !== undefined ? patch.focusCardId : session.focusCardId, + creatorIntent: + patch.creatorIntent !== undefined + ? patch.creatorIntent + : session.creatorIntent, + creatorIntentReadiness: + patch.creatorIntentReadiness ?? session.creatorIntentReadiness, + anchorPack: patch.anchorPack !== undefined ? patch.anchorPack : session.anchorPack, + lockState: patch.lockState !== undefined ? patch.lockState : session.lockState, + draftProfile: + patch.draftProfile !== undefined ? patch.draftProfile : session.draftProfile, + pendingClarifications: + patch.pendingClarifications !== undefined + ? patch.pendingClarifications + : session.pendingClarifications, + suggestedActions: + patch.suggestedActions !== undefined + ? patch.suggestedActions + : session.suggestedActions, + recommendedReplies: + patch.recommendedReplies !== undefined + ? patch.recommendedReplies + : session.recommendedReplies, + draftCards: patch.draftCards !== undefined ? patch.draftCards : session.draftCards, + qualityFindings: + patch.qualityFindings !== undefined + ? patch.qualityFindings + : session.qualityFindings, + assetCoverage: + patch.assetCoverage !== undefined + ? patch.assetCoverage + : session.assetCoverage, + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/expandLongTailExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/expandLongTailExecutor.ts new file mode 100644 index 00000000..343473fb --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/expandLongTailExecutor.ts @@ -0,0 +1,116 @@ +import { getWorldFoundationCardId } from '../customWorldAgentDraftCompiler.js'; +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentEntityGenerationService } from '../customWorldAgentEntityGenerationService.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { buildActionResultMessage } from './helpers.js'; +import { + buildCheckpointSnapshot, + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +export function createExpandLongTailExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + entityGenerationService: CustomWorldAgentEntityGenerationService; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; +}): CustomWorldAgentActionExecutor<'expand_long_tail'> { + return async ({ userId, sessionId, operationId }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '扩展长尾内容', + phaseDetail: '正在补充边缘角色与次级地点,让世界草稿更完整可玩。', + progress: 28, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + const baseDraftProfile = + (latestSession.draftProfile ?? {}) as Record; + const characterResult = + await params.entityGenerationService.generateAdditionalCharacters({ + creatorIntent: latestSession.creatorIntent, + anchorPack: latestSession.anchorPack, + draftProfile: baseDraftProfile, + count: 2, + anchorCardIds: + latestSession.focusCardId && latestSession.focusCardId.trim() + ? [latestSession.focusCardId] + : [getWorldFoundationCardId()], + }); + + await updateOperation({ + phaseLabel: '补充次级地点', + phaseDetail: '正在围绕新线索补齐可承接支线与长尾内容的地点。', + progress: 62, + }); + + const landmarkResult = + await params.entityGenerationService.generateAdditionalLandmarks({ + creatorIntent: latestSession.creatorIntent, + anchorPack: latestSession.anchorPack, + draftProfile: characterResult.draftProfile, + count: 2, + anchorCardIds: + characterResult.generatedCharacters.length > 0 + ? [characterResult.generatedCharacters[0]!.id] + : latestSession.focusCardId && latestSession.focusCardId.trim() + ? [latestSession.focusCardId] + : [getWorldFoundationCardId()], + }); + + const focusCardId = + landmarkResult.generatedLandmarks[0]?.id ?? + characterResult.generatedCharacters[0]?.id ?? + latestSession.focusCardId; + const nextState = params.snapshotBuilder.buildRefiningState({ + previousStage: latestSession.stage, + nextStage: 'long_tail_review', + draftProfile: landmarkResult.draftProfile, + focusCardId, + }); + + await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); + await params.sessionStore.appendCheckpoint(userId, sessionId, { + label: `扩展长尾 ${characterResult.generatedCharacters.length} 角色 / ${landmarkResult.generatedLandmarks.length} 地点`, + snapshot: buildCheckpointSnapshot(latestSession, nextState), + }); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: `已补出 ${characterResult.generatedCharacters.length} 个长尾角色和 ${landmarkResult.generatedLandmarks.length} 个次级地点,当前阶段进入补全长尾内容。`, + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '长尾内容已扩展', + phaseDetail: '长尾角色与次级地点已经补回草稿,可继续收口后进入发布前检查。', + progress: 100, + error: null, + }); + } catch (error) { + await updateOperation({ + status: 'failed', + phaseLabel: '扩展长尾失败', + phaseDetail: '这一轮没有成功补出长尾内容。', + progress: 100, + error: + error instanceof Error ? error.message : 'expand long tail failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/generateCharactersExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/generateCharactersExecutor.ts new file mode 100644 index 00000000..4931f0e5 --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/generateCharactersExecutor.ts @@ -0,0 +1,110 @@ +import { getWorldFoundationCardId } from '../customWorldAgentDraftCompiler.js'; +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentEntityGenerationService } from '../customWorldAgentEntityGenerationService.js'; +import type { CustomWorldAgentChangeSummaryService } from '../customWorldAgentChangeSummaryService.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { buildActionResultMessage } from './helpers.js'; +import { + buildCheckpointSnapshot, + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +export function createGenerateCharactersExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + entityGenerationService: CustomWorldAgentEntityGenerationService; + changeSummaryService: CustomWorldAgentChangeSummaryService; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; +}): CustomWorldAgentActionExecutor<'generate_characters'> { + return async ({ userId, sessionId, operationId, payload }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '生成新角色', + phaseDetail: '正在围绕当前世界底稿补出新角色。', + progress: 32, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + const generationResult = + await params.entityGenerationService.generateAdditionalCharacters({ + creatorIntent: latestSession.creatorIntent, + anchorPack: latestSession.anchorPack, + draftProfile: (latestSession.draftProfile ?? {}) as Record< + string, + unknown + >, + count: payload.count, + promptText: payload.promptText, + anchorCardIds: + payload.anchorCardIds && payload.anchorCardIds.length > 0 + ? payload.anchorCardIds + : latestSession.focusCardId + ? [latestSession.focusCardId] + : [getWorldFoundationCardId()], + }); + + await updateOperation({ + phaseLabel: '插入新角色卡', + phaseDetail: '正在把新角色插回草稿并刷新卡片列表。', + progress: 74, + }); + + const focusCardId = generationResult.generatedCharacters[0]?.id ?? null; + const nextState = params.snapshotBuilder.buildRefiningState({ + previousStage: latestSession.stage, + draftProfile: generationResult.draftProfile, + focusCardId, + }); + + await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); + await params.sessionStore.appendCheckpoint(userId, sessionId, { + label: `新增角色 ${generationResult.generatedCharacters.length} 个`, + snapshot: buildCheckpointSnapshot(latestSession, nextState), + }); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: params.changeSummaryService.buildSummary({ + action: 'generate_characters', + names: generationResult.generatedCharacters.map( + (entry) => entry.name, + ), + draftProfile: generationResult.draftProfile, + }), + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '新角色已加入草稿', + phaseDetail: `已补出 ${generationResult.generatedCharacters.length} 个新角色。`, + progress: 100, + error: null, + }); + } catch (error) { + await updateOperation({ + status: 'failed', + phaseLabel: '角色生成失败', + phaseDetail: '这一轮没有成功补出新角色。', + progress: 100, + error: + error instanceof Error ? error.message : 'generate characters failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/generateLandmarksExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/generateLandmarksExecutor.ts new file mode 100644 index 00000000..1277ff4e --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/generateLandmarksExecutor.ts @@ -0,0 +1,110 @@ +import { getWorldFoundationCardId } from '../customWorldAgentDraftCompiler.js'; +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentEntityGenerationService } from '../customWorldAgentEntityGenerationService.js'; +import type { CustomWorldAgentChangeSummaryService } from '../customWorldAgentChangeSummaryService.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { buildActionResultMessage } from './helpers.js'; +import { + buildCheckpointSnapshot, + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +export function createGenerateLandmarksExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + entityGenerationService: CustomWorldAgentEntityGenerationService; + changeSummaryService: CustomWorldAgentChangeSummaryService; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; +}): CustomWorldAgentActionExecutor<'generate_landmarks'> { + return async ({ userId, sessionId, operationId, payload }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '生成新地点', + phaseDetail: '正在围绕当前世界底稿补出新地点。', + progress: 32, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + const generationResult = + await params.entityGenerationService.generateAdditionalLandmarks({ + creatorIntent: latestSession.creatorIntent, + anchorPack: latestSession.anchorPack, + draftProfile: (latestSession.draftProfile ?? {}) as Record< + string, + unknown + >, + count: payload.count, + promptText: payload.promptText, + anchorCardIds: + payload.anchorCardIds && payload.anchorCardIds.length > 0 + ? payload.anchorCardIds + : latestSession.focusCardId + ? [latestSession.focusCardId] + : [getWorldFoundationCardId()], + }); + + await updateOperation({ + phaseLabel: '插入新地点卡', + phaseDetail: '正在把新地点插回草稿并刷新卡片列表。', + progress: 74, + }); + + const focusCardId = generationResult.generatedLandmarks[0]?.id ?? null; + const nextState = params.snapshotBuilder.buildRefiningState({ + previousStage: latestSession.stage, + draftProfile: generationResult.draftProfile, + focusCardId, + }); + + await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); + await params.sessionStore.appendCheckpoint(userId, sessionId, { + label: `新增地点 ${generationResult.generatedLandmarks.length} 个`, + snapshot: buildCheckpointSnapshot(latestSession, nextState), + }); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: params.changeSummaryService.buildSummary({ + action: 'generate_landmarks', + names: generationResult.generatedLandmarks.map( + (entry) => entry.name, + ), + draftProfile: generationResult.draftProfile, + }), + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '新地点已加入草稿', + phaseDetail: `已补出 ${generationResult.generatedLandmarks.length} 个新地点。`, + progress: 100, + error: null, + }); + } catch (error) { + await updateOperation({ + status: 'failed', + phaseLabel: '地点生成失败', + phaseDetail: '这一轮没有成功补出新地点。', + progress: 100, + error: + error instanceof Error ? error.message : 'generate landmarks failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/generateRoleAssetsExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/generateRoleAssetsExecutor.ts new file mode 100644 index 00000000..2daea938 --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/generateRoleAssetsExecutor.ts @@ -0,0 +1,82 @@ +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { buildActionResultMessage } from './helpers.js'; +import { + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +export function createGenerateRoleAssetsExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + assetBridgeService: CustomWorldAgentAssetBridgeService; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; +}): CustomWorldAgentActionExecutor<'generate_role_assets'> { + return async ({ userId, sessionId, operationId, payload }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '准备角色资产工坊', + phaseDetail: '正在校验角色并整理工坊上下文。', + progress: 40, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + const roleId = payload.roleIds[0]!; + const studioContext = params.assetBridgeService.buildRoleAssetStudioContext( + latestSession.draftProfile, + roleId, + ); + const nextState = params.snapshotBuilder.buildRefiningState({ + previousStage: latestSession.stage, + nextStage: 'visual_refining', + draftProfile: + (latestSession.draftProfile ?? {}) as Record, + draftCards: latestSession.draftCards, + assetCoverage: latestSession.assetCoverage, + focusCardId: roleId, + }); + + await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: `已为「${studioContext.roleName}」准备好角色资产工坊,先生成主图候选,再补核心动作。`, + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '角色资产工坊已就绪', + phaseDetail: `「${studioContext.roleName}」现在可以开始生成主图和动作。`, + progress: 100, + error: null, + }); + } catch (error) { + await updateOperation({ + status: 'failed', + phaseLabel: '角色资产工坊准备失败', + phaseDetail: '这一轮没有成功进入角色资产工坊。', + progress: 100, + error: + error instanceof Error + ? error.message + : 'generate role assets failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/generateSceneAssetsExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/generateSceneAssetsExecutor.ts new file mode 100644 index 00000000..e2e69ad6 --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/generateSceneAssetsExecutor.ts @@ -0,0 +1,88 @@ +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { buildActionResultMessage } from './helpers.js'; +import { + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +export function createGenerateSceneAssetsExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + assetBridgeService: CustomWorldAgentAssetBridgeService; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; +}): CustomWorldAgentActionExecutor<'generate_scene_assets'> { + return async ({ userId, sessionId, operationId, payload }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '准备场景资产工坊', + phaseDetail: '正在校验目标场景并整理场景图工坊上下文。', + progress: 40, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + const sceneId = payload.sceneIds[0]!; + const sceneKind = + latestSession.draftCards.find((entry) => entry.id === sceneId)?.kind === + 'camp' + ? 'camp' + : 'landmark'; + const sceneContext = params.assetBridgeService.buildSceneAssetStudioContext( + latestSession.draftProfile, + sceneId, + sceneKind, + ); + const nextState = params.snapshotBuilder.buildRefiningState({ + previousStage: latestSession.stage, + nextStage: 'visual_refining', + draftProfile: + (latestSession.draftProfile ?? {}) as Record, + draftCards: latestSession.draftCards, + assetCoverage: latestSession.assetCoverage, + focusCardId: sceneId, + }); + + await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: `已为「${sceneContext.sceneName}」准备好场景图工坊,保存生成结果后会自动同步回当前草稿。`, + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '场景资产工坊已就绪', + phaseDetail: `「${sceneContext.sceneName}」现在可以继续生成和确认正式场景图。`, + progress: 100, + error: null, + }); + } catch (error) { + await updateOperation({ + status: 'failed', + phaseLabel: '场景资产工坊准备失败', + phaseDetail: '这一轮没有成功进入场景资产工坊。', + progress: 100, + error: + error instanceof Error + ? error.message + : 'generate scene assets failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/helpers.ts b/server-node/src/services/customWorldAgentActionExecutors/helpers.ts new file mode 100644 index 00000000..1c838f4f --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/helpers.ts @@ -0,0 +1,58 @@ +import crypto from 'node:crypto'; + +import type { CustomWorldAgentMessage } from '../../../../packages/shared/src/contracts/customWorldAgent.js'; +import { + normalizeFoundationDraftProfile, +} from '../customWorldAgentDraftCompiler.js'; + +export function buildRoleAssetSyncResultText(params: { + roleName: string; + assetStatusLabel: string; +}) { + return `已把「${params.roleName}」的角色资产写回草稿,当前状态:${params.assetStatusLabel}。`; +} + +export function buildFoundationDraftAssistantMessage(params: { + relatedOperationId: string; + draftProfile: unknown; + warnings?: string[]; +}) { + const profile = normalizeFoundationDraftProfile(params.draftProfile); + const leadCharacter = profile?.playableNpcs[0]; + const leadLandmark = profile?.landmarks[0]; + const warnings = (params.warnings ?? []).filter(Boolean); + + return { + id: `message-${crypto.randomBytes(8).toString('hex')}`, + role: 'assistant', + kind: 'summary', + text: [ + `我先把第一版世界底稿整理出来了:${profile?.summary || '底稿已经生成完成。'}`, + '', + `当前已经落下来的第一批对象数量是:关键角色 ${profile?.playableNpcs.length ?? 0} 个,关键地点 ${profile?.landmarks.length ?? 0} 个,势力 ${profile?.factions.length ?? 0} 个。`, + `建议你先从“${profile?.name || '世界总卡'}”这张世界总卡看起${leadCharacter ? `,再顺着角色「${leadCharacter.name}」往下细修` : ''}${leadLandmark ? `,地点可以先看「${leadLandmark.name}」` : ''}。`, + ...(warnings.length > 0 + ? [ + '', + `这一轮有 ${warnings.length} 项资产补齐未完成,但不影响世界底稿继续精修。`, + ] + : []), + ].join('\n'), + createdAt: new Date().toISOString(), + relatedOperationId: params.relatedOperationId, + } satisfies CustomWorldAgentMessage; +} + +export function buildActionResultMessage(params: { + relatedOperationId: string; + text: string; +}) { + return { + id: `message-${crypto.randomBytes(8).toString('hex')}`, + role: 'assistant', + kind: 'action_result', + text: params.text, + createdAt: new Date().toISOString(), + relatedOperationId: params.relatedOperationId, + } satisfies CustomWorldAgentMessage; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/index.ts b/server-node/src/services/customWorldAgentActionExecutors/index.ts new file mode 100644 index 00000000..0dd62e53 --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/index.ts @@ -0,0 +1,105 @@ +import type { CustomWorldAgentAutoAssetService } from '../customWorldAgentAutoAssetService.js'; +import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js'; +import type { CustomWorldAgentChangeSummaryService } from '../customWorldAgentChangeSummaryService.js'; +import type { CustomWorldAgentDraftCompiler } from '../customWorldAgentDraftCompiler.js'; +import type { CustomWorldAgentEntityGenerationService } from '../customWorldAgentEntityGenerationService.js'; +import type { CustomWorldAgentFoundationDraftService } from '../customWorldAgentFoundationDraftService.js'; +import type { CustomWorldAgentPublishingService } from '../customWorldAgentPublishingService.js'; +import type { CustomWorldAgentResultSyncService } from '../customWorldAgentResultSyncService.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import { createDraftFoundationExecutor } from './draftFoundationExecutor.js'; +import { createExpandLongTailExecutor } from './expandLongTailExecutor.js'; +import { createGenerateCharactersExecutor } from './generateCharactersExecutor.js'; +import { createGenerateLandmarksExecutor } from './generateLandmarksExecutor.js'; +import { createGenerateRoleAssetsExecutor } from './generateRoleAssetsExecutor.js'; +import { createGenerateSceneAssetsExecutor } from './generateSceneAssetsExecutor.js'; +import { createPublishWorldExecutor } from './publishWorldExecutor.js'; +import { createRevertCheckpointExecutor } from './revertCheckpointExecutor.js'; +import { createSyncResultProfileExecutor } from './syncResultProfileExecutor.js'; +import { createSyncRoleAssetsExecutor } from './syncRoleAssetsExecutor.js'; +import { createSyncSceneAssetsExecutor } from './syncSceneAssetsExecutor.js'; +import type { CustomWorldAgentActionExecutorMap } from './types.js'; +import { createUpdateDraftCardExecutor } from './updateDraftCardExecutor.js'; + +export * from './types.js'; + +export function createCustomWorldAgentActionExecutorMap(params: { + sessionStore: CustomWorldAgentSessionStore; + foundationDraftService: CustomWorldAgentFoundationDraftService; + draftCompiler: CustomWorldAgentDraftCompiler; + entityGenerationService: CustomWorldAgentEntityGenerationService; + changeSummaryService: CustomWorldAgentChangeSummaryService; + assetBridgeService: CustomWorldAgentAssetBridgeService; + autoAssetService: CustomWorldAgentAutoAssetService | null; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; + resultSyncService: CustomWorldAgentResultSyncService; + publishingService: CustomWorldAgentPublishingService; + resolveAuthorDisplayName?: ((userId: string) => Promise) | null; +}): CustomWorldAgentActionExecutorMap { + return { + draft_foundation: createDraftFoundationExecutor({ + sessionStore: params.sessionStore, + foundationDraftService: params.foundationDraftService, + autoAssetService: params.autoAssetService, + snapshotBuilder: params.snapshotBuilder, + }), + update_draft_card: createUpdateDraftCardExecutor({ + sessionStore: params.sessionStore, + draftCompiler: params.draftCompiler, + changeSummaryService: params.changeSummaryService, + snapshotBuilder: params.snapshotBuilder, + }), + sync_result_profile: createSyncResultProfileExecutor({ + sessionStore: params.sessionStore, + resultSyncService: params.resultSyncService, + snapshotBuilder: params.snapshotBuilder, + }), + generate_characters: createGenerateCharactersExecutor({ + sessionStore: params.sessionStore, + entityGenerationService: params.entityGenerationService, + changeSummaryService: params.changeSummaryService, + snapshotBuilder: params.snapshotBuilder, + }), + generate_landmarks: createGenerateLandmarksExecutor({ + sessionStore: params.sessionStore, + entityGenerationService: params.entityGenerationService, + changeSummaryService: params.changeSummaryService, + snapshotBuilder: params.snapshotBuilder, + }), + generate_role_assets: createGenerateRoleAssetsExecutor({ + sessionStore: params.sessionStore, + assetBridgeService: params.assetBridgeService, + snapshotBuilder: params.snapshotBuilder, + }), + sync_role_assets: createSyncRoleAssetsExecutor({ + sessionStore: params.sessionStore, + assetBridgeService: params.assetBridgeService, + snapshotBuilder: params.snapshotBuilder, + }), + generate_scene_assets: createGenerateSceneAssetsExecutor({ + sessionStore: params.sessionStore, + assetBridgeService: params.assetBridgeService, + snapshotBuilder: params.snapshotBuilder, + }), + sync_scene_assets: createSyncSceneAssetsExecutor({ + sessionStore: params.sessionStore, + assetBridgeService: params.assetBridgeService, + snapshotBuilder: params.snapshotBuilder, + }), + expand_long_tail: createExpandLongTailExecutor({ + sessionStore: params.sessionStore, + entityGenerationService: params.entityGenerationService, + snapshotBuilder: params.snapshotBuilder, + }), + publish_world: createPublishWorldExecutor({ + sessionStore: params.sessionStore, + publishingService: params.publishingService, + resolveAuthorDisplayName: params.resolveAuthorDisplayName ?? null, + }), + revert_checkpoint: createRevertCheckpointExecutor({ + sessionStore: params.sessionStore, + snapshotBuilder: params.snapshotBuilder, + }), + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/publishWorldExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/publishWorldExecutor.ts new file mode 100644 index 00000000..b3b4694f --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/publishWorldExecutor.ts @@ -0,0 +1,166 @@ +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentPublishingService } from '../customWorldAgentPublishingService.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { buildActionResultMessage } from './helpers.js'; +import { + buildCheckpointSnapshot, + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +function toText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function extractPublishBlockerMessages(message: string) { + const normalized = message.trim(); + if (!normalized) { + return []; + } + + const detailText = normalized.includes(':') + ? normalized.split(':').slice(1).join(':').trim() + : normalized; + + return detailText + .split(';') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function buildGateFailureMessage(errorMessage: string) { + return [ + '当前世界还不能发布,先把这些阻断项补齐:', + ...(extractPublishBlockerMessages(errorMessage).length > 0 + ? extractPublishBlockerMessages(errorMessage) + : [errorMessage.trim()] + ) + .slice(0, 4) + .map((entry, index) => `${index + 1}. ${entry}`), + ].join('\n'); +} + +function resolvePublishedWorldName(profile: unknown) { + const profileRecord = + profile && typeof profile === 'object' && !Array.isArray(profile) + ? (profile as Record) + : null; + + return toText(profileRecord?.name) || '当前世界'; +} + +export function createPublishWorldExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + publishingService: CustomWorldAgentPublishingService; + resolveAuthorDisplayName?: ((userId: string) => Promise) | null; +}): CustomWorldAgentActionExecutor<'publish_world'> { + return async ({ userId, sessionId, operationId }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '执行发布校验', + phaseDetail: '正在检查角色资产、场景图和主线草稿是否满足发布门槛。', + progress: 28, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + try { + params.publishingService.buildPublishReadiness({ + sessionId, + draftProfile: latestSession.draftProfile, + qualityFindings: latestSession.qualityFindings, + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'publish world failed'; + await params.sessionStore.appendMessage( + userId, + sessionId, + { + id: `message-${Date.now().toString(36)}-publish-warning`, + role: 'assistant', + kind: 'warning', + text: buildGateFailureMessage(errorMessage), + createdAt: new Date().toISOString(), + relatedOperationId: operationId, + }, + ); + throw error; + } + + await updateOperation({ + phaseLabel: '发布正式世界', + phaseDetail: '正在把当前草稿编译成正式世界档案并写入作品库。', + progress: 68, + }); + + const authorDisplayName = params.resolveAuthorDisplayName + ? await params.resolveAuthorDisplayName(userId) + : '玩家'; + const publishResult = await params.publishingService.publishSessionDraft({ + userId, + authorDisplayName: authorDisplayName.trim() || '玩家', + sessionId, + draftProfile: + (latestSession.draftProfile ?? {}) as Record, + qualityFindings: latestSession.qualityFindings, + }); + const worldName = resolvePublishedWorldName(publishResult.publishedProfile); + const publishedQualityFindings = latestSession.qualityFindings.filter( + (entry) => entry.severity !== 'blocker', + ); + const publishedState = { + stage: 'published' as const, + qualityFindings: publishedQualityFindings, + }; + + await params.sessionStore.replaceDerivedState( + userId, + sessionId, + publishedState, + ); + await params.sessionStore.appendCheckpoint(userId, sessionId, { + label: `发布世界 ${worldName}`, + snapshot: buildCheckpointSnapshot(latestSession, publishedState), + }); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: + publishedQualityFindings.length > 0 + ? `世界「${worldName}」已发布,并保留 ${publishedQualityFindings.length} 条 warning 供后续继续优化。` + : `世界「${worldName}」已正式发布,可以进入作品库与世界入口。`, + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '世界已发布', + phaseDetail: `正式世界档案已写入作品库:${publishResult.profileId}。`, + progress: 100, + error: null, + }); + } catch (error) { + await updateOperation({ + status: 'failed', + phaseLabel: '发布失败', + phaseDetail: '当前世界还没有通过发布校验或写入作品库失败。', + progress: 100, + error: error instanceof Error ? error.message : 'publish world failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/revertCheckpointExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/revertCheckpointExecutor.ts new file mode 100644 index 00000000..0c70585c --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/revertCheckpointExecutor.ts @@ -0,0 +1,95 @@ +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { buildActionResultMessage } from './helpers.js'; +import { + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +export function createRevertCheckpointExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; +}): CustomWorldAgentActionExecutor<'revert_checkpoint'> { + return async ({ userId, sessionId, operationId, payload }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '恢复历史检查点', + phaseDetail: '正在把指定检查点的草稿状态恢复到当前会话。', + progress: 36, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + const checkpoint = latestSession.checkpoints.find( + (entry) => entry.checkpointId === payload.checkpointId, + ); + if (!checkpoint?.snapshot) { + throw new Error('目标检查点不存在,或当前检查点还没有可恢复快照。'); + } + + await params.sessionStore.restoreCheckpoint( + userId, + sessionId, + payload.checkpointId, + ); + const restoredSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + const nextState = params.snapshotBuilder.buildRefiningState({ + previousStage: restoredSession.stage, + nextStage: + restoredSession.stage === 'visual_refining' || + restoredSession.stage === 'long_tail_review' || + restoredSession.stage === 'ready_to_publish' + ? restoredSession.stage + : 'object_refining', + draftProfile: + (restoredSession.draftProfile ?? {}) as Record, + focusCardId: restoredSession.focusCardId, + }); + + await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: `已恢复到检查点「${checkpoint.label}」,当前草稿和卡片摘要已经回滚到对应版本。`, + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '检查点已恢复', + phaseDetail: `已恢复到「${checkpoint.label}」。`, + progress: 100, + error: null, + }); + } catch (error) { + await updateOperation({ + status: 'failed', + phaseLabel: '恢复检查点失败', + phaseDetail: '这一轮没有成功恢复历史检查点。', + progress: 100, + error: + error instanceof Error + ? error.message + : 'revert checkpoint failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/syncResultProfileExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/syncResultProfileExecutor.ts new file mode 100644 index 00000000..2058fc3b --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/syncResultProfileExecutor.ts @@ -0,0 +1,87 @@ +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentResultSyncService } from '../customWorldAgentResultSyncService.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { buildActionResultMessage } from './helpers.js'; +import { + buildCheckpointSnapshot, + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +export function createSyncResultProfileExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + resultSyncService: CustomWorldAgentResultSyncService; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; +}): CustomWorldAgentActionExecutor<'sync_result_profile'> { + return async ({ userId, sessionId, operationId, payload }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '同步结果页快照', + phaseDetail: '正在把结果页里的最新世界结构写回当前草稿。', + progress: 36, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + const nextDraftProfile = + params.resultSyncService.syncResultProfileIntoDraftProfile({ + currentDraftProfile: latestSession.draftProfile, + resultProfile: payload.profile as never, + }); + + await updateOperation({ + phaseLabel: '重编译草稿摘要', + phaseDetail: '正在刷新草稿卡摘要、资产覆盖和结果页恢复快照。', + progress: 72, + }); + + const nextState = params.snapshotBuilder.buildRefiningState({ + previousStage: latestSession.stage, + draftProfile: nextDraftProfile, + }); + + await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); + await params.sessionStore.appendCheckpoint(userId, sessionId, { + label: '同步结果页编辑', + snapshot: buildCheckpointSnapshot(latestSession, nextState), + }); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: '结果页里的最新世界结构已经同步回当前草稿。', + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '结果页快照已同步', + phaseDetail: '当前结果页编辑内容已经并回 Agent 草稿主链。', + progress: 100, + error: null, + }); + } catch (error) { + await updateOperation({ + status: 'failed', + phaseLabel: '结果页同步失败', + phaseDetail: '这一轮结果页编辑没有成功写回当前草稿。', + progress: 100, + error: + error instanceof Error ? error.message : 'sync result profile failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/syncRoleAssetsExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/syncRoleAssetsExecutor.ts new file mode 100644 index 00000000..5cd581c3 --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/syncRoleAssetsExecutor.ts @@ -0,0 +1,97 @@ +import { resolveRoleAssetStatusLabel } from '../customWorldAgentRoleAssetStateService.js'; +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { + buildActionResultMessage, + buildRoleAssetSyncResultText, +} from './helpers.js'; +import { + buildCheckpointSnapshot, + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +export function createSyncRoleAssetsExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + assetBridgeService: CustomWorldAgentAssetBridgeService; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; +}): CustomWorldAgentActionExecutor<'sync_role_assets'> { + return async ({ userId, sessionId, operationId, payload }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '同步角色资产', + phaseDetail: '正在把主图与动作结果写回当前世界草稿。', + progress: 36, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + const syncResult = params.assetBridgeService.applyRoleAssetPublishResult( + latestSession.draftProfile, + payload, + ); + + await updateOperation({ + phaseLabel: '刷新角色卡摘要', + phaseDetail: '正在同步更新角色卡状态与资产覆盖。', + progress: 72, + }); + + const nextState = params.snapshotBuilder.buildRefiningState({ + previousStage: latestSession.stage, + nextStage: 'visual_refining', + draftProfile: syncResult.draftProfile, + focusCardId: payload.roleId, + }); + + await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); + await params.sessionStore.appendCheckpoint(userId, sessionId, { + label: `同步角色资产 ${syncResult.updatedAssetSummary.roleName}`, + snapshot: buildCheckpointSnapshot(latestSession, nextState), + }); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: buildRoleAssetSyncResultText({ + roleName: syncResult.updatedAssetSummary.roleName, + assetStatusLabel: resolveRoleAssetStatusLabel( + syncResult.updatedAssetSummary.status, + ), + }), + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '角色资产已同步', + phaseDetail: `「${syncResult.updatedAssetSummary.roleName}」的资产状态已更新为${resolveRoleAssetStatusLabel(syncResult.updatedAssetSummary.status)}。`, + progress: 100, + error: null, + }); + } catch (error) { + await updateOperation({ + status: 'failed', + phaseLabel: '角色资产同步失败', + phaseDetail: '这一轮没有成功把角色资产写回草稿。', + progress: 100, + error: + error instanceof Error ? error.message : 'sync role assets failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/syncSceneAssetsExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/syncSceneAssetsExecutor.ts new file mode 100644 index 00000000..6dc24354 --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/syncSceneAssetsExecutor.ts @@ -0,0 +1,88 @@ +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { buildActionResultMessage } from './helpers.js'; +import { + buildCheckpointSnapshot, + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +export function createSyncSceneAssetsExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + assetBridgeService: CustomWorldAgentAssetBridgeService; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; +}): CustomWorldAgentActionExecutor<'sync_scene_assets'> { + return async ({ userId, sessionId, operationId, payload }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '同步场景资产', + phaseDetail: '正在把营地/地点场景图写回当前世界草稿。', + progress: 38, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + const syncResult = params.assetBridgeService.applySceneAssetPublishResult( + latestSession.draftProfile, + payload, + ); + + await updateOperation({ + phaseLabel: '刷新场景卡摘要', + phaseDetail: '正在更新地点卡、幕背景摘要和场景资产覆盖率。', + progress: 72, + }); + + const nextState = params.snapshotBuilder.buildRefiningState({ + previousStage: latestSession.stage, + nextStage: 'visual_refining', + draftProfile: syncResult.draftProfile, + focusCardId: payload.sceneId, + }); + + await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); + await params.sessionStore.appendCheckpoint(userId, sessionId, { + label: `同步场景资产 ${String(syncResult.updatedScene.name ?? payload.sceneId)}`, + snapshot: buildCheckpointSnapshot(latestSession, nextState), + }); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: `已把「${String(syncResult.updatedScene.name ?? '当前场景')}」的场景图写回草稿,并同步刷新地点卡与幕背景状态。`, + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '场景资产已同步', + phaseDetail: `「${String(syncResult.updatedScene.name ?? '当前场景')}」的场景图已经进入当前草稿。`, + progress: 100, + error: null, + }); + } catch (error) { + await updateOperation({ + status: 'failed', + phaseLabel: '场景资产同步失败', + phaseDetail: '这一轮没有成功把场景图写回当前草稿。', + progress: 100, + error: + error instanceof Error ? error.message : 'sync scene assets failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/types.ts b/server-node/src/services/customWorldAgentActionExecutors/types.ts new file mode 100644 index 00000000..93d10e91 --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/types.ts @@ -0,0 +1,29 @@ +import type { CustomWorldAgentActionRequest } from '../../../../packages/shared/src/contracts/customWorldAgent.js'; + +export type CustomWorldAgentActionPayload< + K extends CustomWorldAgentActionRequest['action'], +> = Extract; + +export type CustomWorldAgentActionExecutor< + K extends CustomWorldAgentActionRequest['action'], +> = (params: { + userId: string; + sessionId: string; + operationId: string; + payload: CustomWorldAgentActionPayload; +}) => Promise; + +export type CustomWorldAgentActionExecutorMap = { + draft_foundation: CustomWorldAgentActionExecutor<'draft_foundation'>; + update_draft_card: CustomWorldAgentActionExecutor<'update_draft_card'>; + sync_result_profile: CustomWorldAgentActionExecutor<'sync_result_profile'>; + generate_characters: CustomWorldAgentActionExecutor<'generate_characters'>; + generate_landmarks: CustomWorldAgentActionExecutor<'generate_landmarks'>; + generate_role_assets: CustomWorldAgentActionExecutor<'generate_role_assets'>; + sync_role_assets: CustomWorldAgentActionExecutor<'sync_role_assets'>; + generate_scene_assets: CustomWorldAgentActionExecutor<'generate_scene_assets'>; + sync_scene_assets: CustomWorldAgentActionExecutor<'sync_scene_assets'>; + expand_long_tail: CustomWorldAgentActionExecutor<'expand_long_tail'>; + publish_world: CustomWorldAgentActionExecutor<'publish_world'>; + revert_checkpoint: CustomWorldAgentActionExecutor<'revert_checkpoint'>; +}; diff --git a/server-node/src/services/customWorldAgentActionExecutors/updateDraftCardExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/updateDraftCardExecutor.ts new file mode 100644 index 00000000..a3a5fcc2 --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/updateDraftCardExecutor.ts @@ -0,0 +1,111 @@ +import { updateDraftCardSections } from '../customWorldAgentDraftEditService.js'; +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentChangeSummaryService } from '../customWorldAgentChangeSummaryService.js'; +import type { CustomWorldAgentDraftCompiler } from '../customWorldAgentDraftCompiler.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { buildActionResultMessage } from './helpers.js'; +import { + buildCheckpointSnapshot, + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +export function createUpdateDraftCardExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + draftCompiler: CustomWorldAgentDraftCompiler; + changeSummaryService: CustomWorldAgentChangeSummaryService; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; +}): CustomWorldAgentActionExecutor<'update_draft_card'> { + return async ({ userId, sessionId, operationId, payload }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '写回草稿设定', + phaseDetail: '正在把这次编辑内容写回当前世界底稿。', + progress: 34, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + const nextDraftProfile = updateDraftCardSections({ + draftProfile: (latestSession.draftProfile ?? {}) as Record< + string, + unknown + >, + cardId: payload.cardId, + sections: payload.sections, + }); + + await updateOperation({ + phaseLabel: '重编译草稿卡', + phaseDetail: '正在同步更新草稿摘要和详情内容。', + progress: 72, + }); + + const nextState = params.snapshotBuilder.buildRefiningState({ + previousStage: latestSession.stage, + draftProfile: nextDraftProfile, + focusCardId: payload.cardId, + }); + const updatedDetail = params.draftCompiler.getDraftCardDetail( + nextDraftProfile, + payload.cardId, + ); + const changedSectionIds = new Set( + payload.sections + .map((section) => section.sectionId.trim()) + .filter(Boolean), + ); + + await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); + await params.sessionStore.appendCheckpoint(userId, sessionId, { + label: `编辑 ${updatedDetail?.title || '草稿卡'}`, + snapshot: buildCheckpointSnapshot(latestSession, nextState), + }); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: params.changeSummaryService.buildSummary({ + action: 'update_draft_card', + cardId: payload.cardId, + changedLabels: + updatedDetail?.sections + .filter((section) => changedSectionIds.has(section.id)) + .map((section) => section.label) ?? [], + draftProfile: nextDraftProfile, + }), + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '草稿设定已保存', + phaseDetail: `「${updatedDetail?.title || '当前卡片'}」的设定已经同步更新。`, + progress: 100, + error: null, + }); + } catch (error) { + await updateOperation({ + status: 'failed', + phaseLabel: '保存失败', + phaseDetail: '这次草稿编辑没有成功写回到底稿。', + progress: 100, + error: + error instanceof Error ? error.message : 'update draft card failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionRegistry.test.ts b/server-node/src/services/customWorldAgentActionRegistry.test.ts new file mode 100644 index 00000000..00b0ca89 --- /dev/null +++ b/server-node/src/services/customWorldAgentActionRegistry.test.ts @@ -0,0 +1,260 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { CustomWorldAgentActionExecutorMap } from './customWorldAgentActionExecutors/index.js'; +import { CustomWorldAgentActionRegistry } from './customWorldAgentActionRegistry.js'; +import { createRpgAgentSessionFixture } from '../../../packages/shared/src/contracts/rpgCreationFixtures.js'; + +function createExecutorLog() { + const calls: Array<{ + action: keyof CustomWorldAgentActionExecutorMap; + payload: unknown; + userId: string; + sessionId: string; + operationId: string; + }> = []; + + const createExecutor = ( + action: K, + ): CustomWorldAgentActionExecutorMap[K] => { + return (async (params) => { + calls.push({ + action, + payload: params.payload, + userId: params.userId, + sessionId: params.sessionId, + operationId: params.operationId, + }); + }) as CustomWorldAgentActionExecutorMap[K]; + }; + + return { + calls, + executors: { + draft_foundation: createExecutor('draft_foundation'), + update_draft_card: createExecutor('update_draft_card'), + sync_result_profile: createExecutor('sync_result_profile'), + generate_characters: createExecutor('generate_characters'), + generate_landmarks: createExecutor('generate_landmarks'), + generate_role_assets: createExecutor('generate_role_assets'), + sync_role_assets: createExecutor('sync_role_assets'), + generate_scene_assets: createExecutor('generate_scene_assets'), + sync_scene_assets: createExecutor('sync_scene_assets'), + expand_long_tail: createExecutor('expand_long_tail'), + publish_world: createExecutor('publish_world'), + revert_checkpoint: createExecutor('revert_checkpoint'), + } satisfies CustomWorldAgentActionExecutorMap, + }; +} + +function createSessionRecord(overrides: Partial> = {}) { + const session = createRpgAgentSessionFixture(); + + return { + ...JSON.parse(JSON.stringify(session)), + userId: 'fixture-user', + seedText: '被海雾吞没的旧航路群岛', + operations: [], + checkpoints: [], + createdAt: session.updatedAt, + updatedAt: session.updatedAt, + ...overrides, + }; +} + +test('action registry exposes supported actions with stage-aware enablement and disabled reasons', () => { + const { executors } = createExecutorLog(); + const registry = new CustomWorldAgentActionRegistry(executors); + const session = createSessionRecord({ + stage: 'foundation_review', + progressPercent: 80, + }); + const supportedActions = registry.buildSupportedActions(session as never); + const draftFoundation = supportedActions.find( + (entry) => entry.action === 'draft_foundation', + ); + const syncResultProfile = supportedActions.find( + (entry) => entry.action === 'sync_result_profile', + ); + const publishWorld = supportedActions.find( + (entry) => entry.action === 'publish_world', + ); + const expandLongTail = supportedActions.find( + (entry) => entry.action === 'expand_long_tail', + ); + const revertCheckpoint = supportedActions.find( + (entry) => entry.action === 'revert_checkpoint', + ); + + assert.equal(draftFoundation?.enabled, false); + assert.match(draftFoundation?.reason ?? '', /progressPercent >= 100/u); + assert.equal(syncResultProfile?.enabled, false); + assert.match( + syncResultProfile?.reason ?? '', + /object_refining or visual_refining/u, + ); + assert.equal(publishWorld?.enabled, false); + assert.match( + publishWorld?.reason ?? '', + /object_refining, visual_refining, long_tail_review or ready_to_publish/u, + ); + assert.equal(expandLongTail?.enabled, false); + assert.match( + expandLongTail?.reason ?? '', + /object_refining, visual_refining, long_tail_review or ready_to_publish/u, + ); + assert.equal(revertCheckpoint?.enabled, false); + assert.match( + revertCheckpoint?.reason ?? '', + /requires at least one restorable checkpoint snapshot/u, + ); +}); + +test('action registry enables long-tail and publish actions in late stages, and exposes revert when restorable checkpoint exists', () => { + const { executors } = createExecutorLog(); + const registry = new CustomWorldAgentActionRegistry(executors); + const session = createSessionRecord({ + stage: 'ready_to_publish', + checkpoints: [ + { + checkpointId: 'checkpoint-1', + createdAt: '2026-04-21T12:00:00.000Z', + label: '可回滚版本', + snapshot: { + currentTurn: 2, + anchorContent: createSessionRecord().anchorContent, + progressPercent: 100, + lastAssistantReply: '已生成草稿。', + stage: 'object_refining', + focusCardId: 'world-foundation', + creatorIntent: {}, + creatorIntentReadiness: { + isReady: true, + completedKeys: [], + missingKeys: [], + }, + anchorPack: {}, + lockState: {}, + draftProfile: createSessionRecord().draftProfile, + pendingClarifications: [], + suggestedActions: [], + recommendedReplies: [], + draftCards: createSessionRecord().draftCards, + qualityFindings: [], + assetCoverage: createSessionRecord().assetCoverage, + }, + }, + ], + }); + + const supportedActions = registry.buildSupportedActions(session as never); + + assert.equal( + supportedActions.find((entry) => entry.action === 'expand_long_tail')?.enabled, + true, + ); + assert.equal( + supportedActions.find((entry) => entry.action === 'publish_world')?.enabled, + true, + ); + assert.equal( + supportedActions.find((entry) => entry.action === 'revert_checkpoint')?.enabled, + true, + ); +}); + +test('action registry validates sync_scene_assets required payload and dispatches scene action executors', async () => { + const { calls, executors } = createExecutorLog(); + const registry = new CustomWorldAgentActionRegistry(executors); + const session = createSessionRecord({ + stage: 'visual_refining', + }); + + assert.throws( + () => + registry.prepareExecution(session as never, { + action: 'sync_scene_assets', + sceneId: 'camp-home', + sceneKind: 'camp', + imageSrc: '', + generatedSceneAssetId: 'scene-asset-1', + }), + /imageSrc and generatedSceneAssetId/u, + ); + + const prepared = registry.prepareExecution(session as never, { + action: 'generate_scene_assets', + sceneIds: ['camp-home'], + }); + + assert.equal(prepared.operationType, 'generate_scene_assets'); + + await prepared.execute({ + userId: 'fixture-user', + sessionId: 'fixture-session', + operationId: 'operation-scene-1', + }); + + assert.equal(calls.at(-1)?.action, 'generate_scene_assets'); +}); + +test('action registry normalizes sync_result_profile payload before dispatching executor', async () => { + const { calls, executors } = createExecutorLog(); + const registry = new CustomWorldAgentActionRegistry(executors); + const session = createSessionRecord({ + stage: 'object_refining', + }); + const prepared = registry.prepareExecution(session as never, { + action: 'sync_result_profile', + profile: { + id: 'profile-1', + settingText: '潮雾列岛', + name: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '结果页确认版。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清真相。', + templateWorldType: 'WUXIA', + majorFactions: ['守灯会'], + coreConflicts: ['争夺旧航路控制权'], + playableNpcs: [], + storyNpcs: [], + items: [], + landmarks: [], + generationMode: 'full', + generationStatus: 'complete', + }, + }); + + assert.equal(prepared.operationType, 'sync_result_profile'); + + await prepared.execute({ + userId: 'fixture-user', + sessionId: 'fixture-session', + operationId: 'operation-1', + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0]?.action, 'sync_result_profile'); + assert.equal( + (calls[0]?.payload as { profile?: { name?: string } })?.profile?.name, + '潮雾列岛', + ); +}); + +test('action registry rejects invalid generate_role_assets payload with unit-level validation', () => { + const { executors } = createExecutorLog(); + const registry = new CustomWorldAgentActionRegistry(executors); + const session = createSessionRecord({ + stage: 'object_refining', + }); + + assert.throws( + () => + registry.prepareExecution(session as never, { + action: 'generate_role_assets', + roleIds: ['playable-1', 'story-1'], + }), + /exactly one roleId/u, + ); +}); diff --git a/server-node/src/services/customWorldAgentActionRegistry.ts b/server-node/src/services/customWorldAgentActionRegistry.ts new file mode 100644 index 00000000..07f7f133 --- /dev/null +++ b/server-node/src/services/customWorldAgentActionRegistry.ts @@ -0,0 +1,403 @@ +import type { + CustomWorldAgentActionRequest, + CustomWorldAgentOperationRecord, + CustomWorldSupportedAction, +} from '../../../packages/shared/src/contracts/customWorldAgent.js'; +import { badRequest } from '../errors.js'; +import { normalizeCustomWorldProfile } from '../modules/custom-world/runtimeProfile.js'; +import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; +import type { + CustomWorldAgentActionExecutorMap, + CustomWorldAgentActionPayload, +} from './customWorldAgentActionExecutors/index.js'; +import type { CustomWorldAgentSessionRecord } from './customWorldAgentSessionStore.js'; + +type EnabledAction = keyof CustomWorldAgentActionExecutorMap; +type EnabledDescriptor = { + operationType: CustomWorldAgentOperationRecord['type']; + normalizePayload?: ( + payload: CustomWorldAgentActionPayload, + ) => CustomWorldAgentActionPayload; + validate?: ( + session: CustomWorldAgentSessionRecord, + payload: CustomWorldAgentActionPayload, + ) => void; + execute: CustomWorldAgentActionExecutorMap[K]; +}; +type DisabledAction = Exclude; +type DisabledDescriptor = { + disabledReason: string; +}; + +type ActionCapabilityState = { + enabled: boolean; + reason?: string; +}; + +function assertDraftRefiningActionAvailable( + session: CustomWorldAgentSessionRecord, + action: string, +) { + if ( + session.stage !== 'object_refining' && + session.stage !== 'visual_refining' + ) { + throw badRequest( + `${action} is only available during object_refining or visual_refining`, + ); + } + + const hasDraftFoundation = Boolean( + normalizeFoundationDraftProfile(session.draftProfile) && + session.draftCards.length > 0, + ); + if (!hasDraftFoundation) { + throw badRequest(`${action} requires an existing draft foundation`); + } +} + +function assertLongTailActionAvailable( + session: CustomWorldAgentSessionRecord, + action: string, +) { + if ( + session.stage !== 'object_refining' && + session.stage !== 'visual_refining' && + session.stage !== 'long_tail_review' && + session.stage !== 'ready_to_publish' + ) { + throw badRequest( + `${action} is only available during object_refining, visual_refining, long_tail_review or ready_to_publish`, + ); + } +} + +function assertPublishActionAvailable( + session: CustomWorldAgentSessionRecord, + action: string, +) { + assertLongTailActionAvailable(session, action); + if (!normalizeFoundationDraftProfile(session.draftProfile)) { + throw badRequest(`${action} requires an existing draft foundation`); + } +} + +export type PreparedCustomWorldAgentActionExecution = { + operationType: CustomWorldAgentOperationRecord['type']; + execute: (params: { + userId: string; + sessionId: string; + operationId: string; + }) => Promise; +}; + +export class CustomWorldAgentActionRegistry { + private readonly descriptors: Record< + CustomWorldAgentActionRequest['action'], + EnabledDescriptor | DisabledDescriptor + >; + + constructor(executors: CustomWorldAgentActionExecutorMap) { + this.descriptors = { + draft_foundation: { + operationType: 'draft_foundation', + validate: (session) => { + if (session.progressPercent < 100) { + throw badRequest('draft_foundation requires progressPercent >= 100'); + } + }, + execute: executors.draft_foundation, + }, + update_draft_card: { + operationType: 'update_draft_card', + validate: (session, payload) => { + assertDraftRefiningActionAvailable(session, payload.action); + if (!payload.cardId.trim()) { + throw badRequest('update_draft_card requires cardId'); + } + if (!Array.isArray(payload.sections) || payload.sections.length === 0) { + throw badRequest('update_draft_card requires sections'); + } + }, + execute: executors.update_draft_card, + }, + sync_result_profile: { + operationType: 'sync_result_profile', + normalizePayload: (payload) => { + const normalizedProfile = normalizeCustomWorldProfile(payload.profile, ''); + if (!normalizedProfile) { + throw badRequest('sync_result_profile requires a valid profile'); + } + + return { + ...payload, + profile: normalizedProfile as unknown as Record, + }; + }, + validate: (session, payload) => { + assertDraftRefiningActionAvailable(session, payload.action); + }, + execute: executors.sync_result_profile, + }, + generate_characters: { + operationType: 'generate_characters', + validate: (session, payload) => { + assertDraftRefiningActionAvailable(session, payload.action); + if (payload.count < 1 || payload.count > 3) { + throw badRequest( + 'generate_characters count must be between 1 and 3', + ); + } + }, + execute: executors.generate_characters, + }, + generate_landmarks: { + operationType: 'generate_landmarks', + validate: (session, payload) => { + assertDraftRefiningActionAvailable(session, payload.action); + if (payload.count < 1 || payload.count > 3) { + throw badRequest( + 'generate_landmarks count must be between 1 and 3', + ); + } + }, + execute: executors.generate_landmarks, + }, + generate_role_assets: { + operationType: 'generate_role_assets', + validate: (session, payload) => { + assertDraftRefiningActionAvailable(session, payload.action); + if (!Array.isArray(payload.roleIds) || payload.roleIds.length !== 1) { + throw badRequest( + 'generate_role_assets currently requires exactly one roleId', + ); + } + }, + execute: executors.generate_role_assets, + }, + sync_role_assets: { + operationType: 'sync_role_assets', + validate: (session, payload) => { + assertDraftRefiningActionAvailable(session, payload.action); + if (!payload.roleId.trim()) { + throw badRequest('sync_role_assets requires roleId'); + } + if ( + !payload.portraitPath.trim() || + !payload.generatedVisualAssetId.trim() + ) { + throw badRequest( + 'sync_role_assets requires portraitPath and generatedVisualAssetId', + ); + } + }, + execute: executors.sync_role_assets, + }, + generate_scene_assets: { + operationType: 'generate_scene_assets', + validate: (session, payload) => { + assertDraftRefiningActionAvailable(session, payload.action); + if (!Array.isArray(payload.sceneIds) || payload.sceneIds.length !== 1) { + throw badRequest( + 'generate_scene_assets currently requires exactly one sceneId', + ); + } + }, + execute: executors.generate_scene_assets, + }, + sync_scene_assets: { + operationType: 'sync_scene_assets', + validate: (session, payload) => { + assertDraftRefiningActionAvailable(session, payload.action); + if (!payload.sceneId.trim()) { + throw badRequest('sync_scene_assets requires sceneId'); + } + if (!payload.imageSrc.trim() || !payload.generatedSceneAssetId.trim()) { + throw badRequest( + 'sync_scene_assets requires imageSrc and generatedSceneAssetId', + ); + } + }, + execute: executors.sync_scene_assets, + }, + expand_long_tail: { + operationType: 'expand_long_tail', + validate: (session, payload) => { + assertLongTailActionAvailable(session, payload.action); + if (!normalizeFoundationDraftProfile(session.draftProfile)) { + throw badRequest('expand_long_tail requires an existing draft foundation'); + } + }, + execute: executors.expand_long_tail, + }, + publish_world: { + operationType: 'publish_world', + validate: (session, payload) => { + assertPublishActionAvailable(session, payload.action); + }, + execute: executors.publish_world, + }, + revert_checkpoint: { + operationType: 'revert_checkpoint', + validate: (session, payload) => { + assertLongTailActionAvailable(session, payload.action); + if (!payload.checkpointId.trim()) { + throw badRequest('revert_checkpoint requires checkpointId'); + } + const checkpoint = session.checkpoints.find( + (entry) => entry.checkpointId === payload.checkpointId, + ); + if (!checkpoint) { + throw badRequest('revert_checkpoint target checkpoint does not exist'); + } + if (!checkpoint.snapshot) { + throw badRequest( + 'revert_checkpoint target checkpoint does not contain a restorable snapshot', + ); + } + }, + execute: executors.revert_checkpoint, + }, + }; + } + + // orchestrator 只关心“拿到一个已校验的动作执行计划”,不再自己维护所有 action 分支。 + prepareExecution( + session: CustomWorldAgentSessionRecord, + payload: CustomWorldAgentActionRequest, + ): PreparedCustomWorldAgentActionExecution { + const descriptor = this.descriptors[payload.action]; + if ('disabledReason' in descriptor) { + throw badRequest(descriptor.disabledReason); + } + + const normalizedPayload = descriptor.normalizePayload + ? descriptor.normalizePayload(payload as never) + : payload; + + descriptor.validate?.(session, normalizedPayload as never); + + return { + operationType: descriptor.operationType, + execute: ({ userId, sessionId, operationId }) => + descriptor.execute({ + userId, + sessionId, + operationId, + payload: normalizedPayload as never, + }), + }; + } + + buildSupportedActions( + session: CustomWorldAgentSessionRecord, + ): CustomWorldSupportedAction[] { + return ( + Object.entries(this.descriptors) as Array< + [ + CustomWorldAgentActionRequest['action'], + EnabledDescriptor | DisabledDescriptor, + ] + > + ).map(([action, descriptor]) => { + const capability = this.resolveCapabilityState(session, action, descriptor); + + return { + action, + enabled: capability.enabled, + reason: capability.reason ?? null, + } satisfies CustomWorldSupportedAction; + }); + } + + private resolveCapabilityState( + session: CustomWorldAgentSessionRecord, + action: CustomWorldAgentActionRequest['action'], + descriptor: EnabledDescriptor | DisabledDescriptor, + ): ActionCapabilityState { + if ('disabledReason' in descriptor) { + return { + enabled: false, + reason: descriptor.disabledReason, + }; + } + + if (action === 'draft_foundation') { + return session.progressPercent >= 100 + ? { enabled: true } + : { + enabled: false, + reason: 'draft_foundation requires progressPercent >= 100', + }; + } + + if ( + action === 'update_draft_card' || + action === 'sync_result_profile' || + action === 'generate_characters' || + action === 'generate_landmarks' || + action === 'generate_role_assets' || + action === 'sync_role_assets' || + action === 'generate_scene_assets' || + action === 'sync_scene_assets' + ) { + try { + assertDraftRefiningActionAvailable(session, action); + return { enabled: true }; + } catch (error) { + return { + enabled: false, + reason: error instanceof Error ? error.message : 'action unavailable', + }; + } + } + + if (action === 'expand_long_tail') { + try { + assertLongTailActionAvailable(session, action); + return { enabled: true }; + } catch (error) { + return { + enabled: false, + reason: error instanceof Error ? error.message : 'action unavailable', + }; + } + } + + if (action === 'publish_world') { + try { + assertPublishActionAvailable(session, action); + return { enabled: true }; + } catch (error) { + return { + enabled: false, + reason: error instanceof Error ? error.message : 'action unavailable', + }; + } + } + + if (action === 'revert_checkpoint') { + const restorableCheckpoint = session.checkpoints.find( + (entry) => Boolean(entry.snapshot), + ); + if (!restorableCheckpoint) { + return { + enabled: false, + reason: 'revert_checkpoint requires at least one restorable checkpoint snapshot', + }; + } + + try { + assertLongTailActionAvailable(session, action); + return { enabled: true }; + } catch (error) { + return { + enabled: false, + reason: error instanceof Error ? error.message : 'action unavailable', + }; + } + } + + return { enabled: true }; + } +} diff --git a/server-node/src/services/customWorldAgentAssetBridgeService.ts b/server-node/src/services/customWorldAgentAssetBridgeService.ts index 21de5ab5..6f1d4378 100644 --- a/server-node/src/services/customWorldAgentAssetBridgeService.ts +++ b/server-node/src/services/customWorldAgentAssetBridgeService.ts @@ -1,6 +1,10 @@ -import type { CustomWorldRoleAssetSummary } from '../../../packages/shared/src/contracts/customWorldAgent.js'; +import type { + CustomWorldRoleAssetSummary, + CustomWorldSceneAssetSummary, +} from '../../../packages/shared/src/contracts/customWorldAgent.js'; import { getRoleAssetSummaryById, + rebuildRoleAssetCoverage, mergeRoleAssetIntoDraftProfile, } from './customWorldAgentRoleAssetStateService.js'; @@ -31,6 +35,17 @@ type SyncRoleAssetsPayload = { animationMap?: Record | 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; @@ -38,6 +53,97 @@ export type SyncRoleAssetsResult = { draftProfile: Record; }; +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; + updatedAssetSummaries: CustomWorldSceneAssetSummary[]; + draftProfile: Record; +}; + +function cloneRecord>(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function toSceneDescription(scene: Record, 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, + sceneId: string, +) { + return toRecordArray(draftProfile.sceneChapters) + .filter((chapter) => toText(chapter.sceneId) === sceneId) + .flatMap((chapter) => toRecordArray(chapter.acts)); +} + +function updateSceneChapterActsForScene(params: { + draftProfile: Record; + 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; + }); +} + +function buildSceneAssetFallbackSummary(params: { + sceneId: string; + sceneKind: SceneKind; + updatedScene: Record; + 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); @@ -96,4 +202,123 @@ export class CustomWorldAgentAssetBridgeService { 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 | 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, + }; + } } diff --git a/server-node/src/services/customWorldAgentDraftCompiler.ts b/server-node/src/services/customWorldAgentDraftCompiler.ts index 6911e061..42f2abd8 100644 --- a/server-node/src/services/customWorldAgentDraftCompiler.ts +++ b/server-node/src/services/customWorldAgentDraftCompiler.ts @@ -377,6 +377,9 @@ function normalizeLandmark( secret: secret || '玩家第一次抵达就会意识到它不只是背景', dangerLevel: dangerLevel || '中', imageSrc: toText(record.imageSrc) || null, + generatedSceneAssetId: toText(record.generatedSceneAssetId) || null, + generatedScenePrompt: toText(record.generatedScenePrompt) || null, + generatedSceneModel: toText(record.generatedSceneModel) || null, characterIds: toStringArray(record.characterIds, 8), threadIds: toStringArray(record.threadIds, 8), summary: @@ -501,6 +504,9 @@ function normalizeCamp(value: unknown): CustomWorldFoundationDraftCamp | null { mood: dangerLevel || '克制、紧绷,但还能暂时收拢局势', dangerLevel: dangerLevel || '克制、紧绷,但还能暂时收拢局势', imageSrc: toText(record.imageSrc) || null, + generatedSceneAssetId: toText(record.generatedSceneAssetId) || null, + generatedScenePrompt: toText(record.generatedScenePrompt) || null, + generatedSceneModel: toText(record.generatedSceneModel) || null, summary: summary || clampText( @@ -1060,6 +1066,9 @@ function buildLandmarkWarnings(landmark: CustomWorldFoundationDraftLandmark) { if (landmark.threadIds.length === 0) { warnings.push('这个地点还缺少更清楚的线程挂钩。'); } + if (!landmark.imageSrc || !landmark.generatedSceneAssetId) { + warnings.push('这个地点还没有绑定正式场景图。'); + } return warnings; } @@ -1163,8 +1172,12 @@ function buildSceneChapterWarnings(params: { return warnings; } -function buildCampWarnings() { - return [] as string[]; +function buildCampWarnings(camp: CustomWorldFoundationDraftCamp) { + const warnings: string[] = []; + if (!camp.imageSrc || !camp.generatedSceneAssetId) { + warnings.push('营地还没有绑定正式场景图。'); + } + return warnings; } function buildCharacterAssetHeadline(character: CustomWorldFoundationDraftCharacter) { @@ -1332,12 +1345,22 @@ export class CustomWorldAgentDraftCompiler { }); if (profile.camp) { - const campWarnings = buildCampWarnings(); + const campWarnings = buildCampWarnings(profile.camp); pushCard({ id: profile.camp.id, kind: 'camp', title: profile.camp.name, - subtitle: clampText(profile.camp.mood || '开局落脚处', 28), + subtitle: clampText( + [ + profile.camp.mood || '开局落脚处', + profile.camp.imageSrc && profile.camp.generatedSceneAssetId + ? '背景图已就绪' + : '待生成背景图', + ] + .filter(Boolean) + .join(' / '), + 28, + ), summary: profile.camp.summary, linkedIds: [ ...profile.landmarks.slice(0, 2).map((entry) => entry.id), @@ -1347,14 +1370,21 @@ export class CustomWorldAgentDraftCompiler { sections: [ buildSection('name', '营地名称', profile.camp.name), buildSection('description', '当前定位', profile.camp.description), - buildSection( - 'dangerLevel', - '危险等级', - profile.camp.dangerLevel || profile.camp.mood, - ), - buildSection( - 'linkedObjects', - '关联对象', + buildSection( + 'dangerLevel', + '危险等级', + profile.camp.dangerLevel || profile.camp.mood, + ), + buildSection( + 'sceneAsset', + '场景资产', + profile.camp.imageSrc || profile.camp.generatedSceneAssetId + ? '正式场景图已就绪' + : '待生成正式场景图', + ), + buildSection( + 'linkedObjects', + '关联对象', [ resolveLandmarkNames( profile.landmarks.slice(0, 2).map((entry) => entry.id), @@ -1490,22 +1520,39 @@ export class CustomWorldAgentDraftCompiler { profile.landmarks.forEach((landmark) => { const warnings = buildLandmarkWarnings(landmark); - pushCard({ - id: landmark.id, - kind: 'landmark', - title: landmark.name, - subtitle: clampText(landmark.purpose || landmark.mood, 28), - summary: landmark.summary, - linkedIds: [...landmark.characterIds, ...landmark.threadIds].slice(0, 8), - sections: [ - buildSection('name', '地点名', landmark.name), - buildSection('purpose', '地点定位', landmark.purpose), - buildSection('mood', '场景情绪', landmark.mood), - buildSection('secret', '隐藏秘密', landmark.secret || landmark.importance), - buildSection('summary', '地点摘要', landmark.summary), - buildSection( - 'characterIds', - '关联角色', + pushCard({ + id: landmark.id, + kind: 'landmark', + title: landmark.name, + subtitle: clampText( + [ + landmark.purpose || landmark.mood, + landmark.imageSrc && landmark.generatedSceneAssetId + ? '背景图已就绪' + : '待生成背景图', + ] + .filter(Boolean) + .join(' / '), + 28, + ), + summary: landmark.summary, + linkedIds: [...landmark.characterIds, ...landmark.threadIds].slice(0, 8), + sections: [ + buildSection('name', '地点名', landmark.name), + buildSection('purpose', '地点定位', landmark.purpose), + buildSection('mood', '场景情绪', landmark.mood), + buildSection('secret', '隐藏秘密', landmark.secret || landmark.importance), + buildSection('summary', '地点摘要', landmark.summary), + buildSection( + 'sceneAsset', + '场景资产', + landmark.imageSrc || landmark.generatedSceneAssetId + ? '正式场景图已就绪' + : '待生成正式场景图', + ), + buildSection( + 'characterIds', + '关联角色', resolveCharacterNames(landmark.characterIds), ), buildSection( diff --git a/server-node/src/services/customWorldAgentFoundationDraftService.test.ts b/server-node/src/services/customWorldAgentFoundationDraftService.test.ts new file mode 100644 index 00000000..45674c11 --- /dev/null +++ b/server-node/src/services/customWorldAgentFoundationDraftService.test.ts @@ -0,0 +1,324 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { UpstreamLlmClient } from './llmClient.js'; +import { CustomWorldAgentFoundationDraftService } from './customWorldAgentFoundationDraftService.js'; +import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; + +function createFoundationDraftLlmClient(): UpstreamLlmClient { + let roleOutlineBatch = 0; + let landmarkSeedBatch = 0; + let landmarkNetworkBatch = 0; + let playableNarrativeBatch = 0; + let playableDossierBatch = 0; + let storyNarrativeBatch = 0; + let storyDossierBatch = 0; + + return { + requestMessageContent: async (params) => { + const debugLabel = params.debugLabel ?? ''; + + if (debugLabel === 'agent-foundation-framework') { + return JSON.stringify({ + name: '潮雾列岛', + subtitle: '盐火灯塔与失控航路', + summary: '潮雾列岛正在被假航灯和沉船商盟重新切开。', + tone: '冷峻、潮湿、悬疑', + playerGoal: '先确认谁在操盘假航灯,再决定自己站在哪一边。', + templateWorldType: 'WUXIA', + majorFactions: ['守灯会', '沉船商盟'], + coreConflicts: ['守灯会与沉船商盟争夺旧航路解释权'], + camp: { + name: '雾湾前哨', + description: '玩家在盐火灯塔下方临时收束线索的地方。', + dangerLevel: 'medium', + }, + playableNpcs: [], + storyNpcs: [], + landmarks: [], + }); + } + + if (debugLabel.startsWith('agent-foundation-playable-outline-batch-')) { + roleOutlineBatch += 1; + return JSON.stringify({ + playableNpcs: [ + { + name: '返灯人', + title: '失职守灯人', + role: '玩家前线身份', + description: '被迫返乡的守灯人,刚站回快要熄灭的旧灯塔。', + visualDescription: '风衣上沾着盐霜,眼底始终没有睡意。', + actionDescription: '先守住灯塔,再判断该不该相信旧友。', + sceneVisualDescription: '每次钟声响起时,他都像被过去拽住。', + initialAffinity: 18, + relationshipHooks: ['这是玩家贴近世界的第一切口'], + tags: ['玩家视角', '守灯'], + }, + ], + }); + } + + if (debugLabel.startsWith('agent-foundation-story-outline-batch-')) { + roleOutlineBatch += 1; + return JSON.stringify({ + storyNpcs: [ + { + name: '沈砺', + title: '旧友兼宿敌', + role: '沉船商盟引路人', + description: '他像旧友,也像最早知道假航灯秘密的人。', + visualDescription: '衣角总带着潮水味,像是刚从夜雾里走出来。', + actionDescription: '会不断试探玩家到底愿不愿意回到旧航路。', + sceneVisualDescription: '总在钟声停下后的空隙里现身。', + initialAffinity: 6, + relationshipHooks: ['和玩家共享一段无法翻篇的旧灯塔往事'], + tags: ['旧友', '宿敌'], + }, + { + name: '岚珀', + title: '守灯会巡夜官', + role: '守灯会前台接口人', + description: '她负责把守灯会的怀疑与命令直接压到玩家面前。', + visualDescription: '披着潮湿斗篷,眼神总先看灯芯再看人。', + actionDescription: '要求玩家立刻证明自己还配站回灯塔。', + sceneVisualDescription: '总把巡夜灯举得很高,不给人躲闪空间。', + initialAffinity: 6, + relationshipHooks: ['会逼玩家更早站队'], + tags: ['守灯会', '巡夜'], + }, + ], + }); + } + + if (debugLabel.startsWith('agent-foundation-landmark-seed-batch-')) { + landmarkSeedBatch += 1; + return JSON.stringify({ + landmarks: [ + { + name: '盐火灯塔', + description: '旧灯塔正在熄灭边缘摇晃,所有人都盯着它的火。', + visualDescription: '塔身被盐霜和旧火痕反复覆盖。', + dangerLevel: 'high', + sceneNpcNames: ['沈砺', '岚珀'], + connections: [], + }, + { + name: '沉船码头', + description: '假航灯把沉船和黑市都引到了这片雾港。', + visualDescription: '破碎船骨和黑帆在潮雾里若隐若现。', + dangerLevel: 'high', + sceneNpcNames: ['沈砺'], + connections: [], + }, + ], + }); + } + + if (debugLabel.startsWith('agent-foundation-landmark-network-batch-')) { + landmarkNetworkBatch += 1; + return JSON.stringify({ + landmarks: [ + { + name: '盐火灯塔', + description: '旧灯塔正在熄灭边缘摇晃,所有人都盯着它的火。', + visualDescription: '塔身被盐霜和旧火痕反复覆盖。', + dangerLevel: 'high', + sceneNpcNames: ['沈砺', '岚珀'], + connections: [ + { + targetLandmarkName: '沉船码头', + relativePosition: 'forward', + summary: '顺着残灯下的潮道走,就会被拖进沉船码头。', + }, + ], + }, + { + name: '沉船码头', + description: '假航灯把沉船和黑市都引到了这片雾港。', + visualDescription: '破碎船骨和黑帆在潮雾里若隐若现。', + dangerLevel: 'high', + sceneNpcNames: ['沈砺'], + connections: [ + { + targetLandmarkName: '盐火灯塔', + relativePosition: 'back', + summary: '码头所有线头最终都会重新指回灯塔。', + }, + ], + }, + ], + }); + } + + if (debugLabel.startsWith('agent-foundation-playable-narrative-batch-')) { + playableNarrativeBatch += 1; + return JSON.stringify({ + playableNpcs: [ + { + name: '返灯人', + title: '失职守灯人', + role: '玩家前线身份', + description: '被迫返乡的守灯人,刚站回快要熄灭的旧灯塔。', + visualDescription: '风衣上沾着盐霜,眼底始终没有睡意。', + actionDescription: '先守住灯塔,再判断该不该相信旧友。', + sceneVisualDescription: '每次钟声响起时,他都像被过去拽住。', + relationshipHooks: ['这是玩家贴近世界的第一切口'], + tags: ['玩家视角', '守灯'], + }, + ], + }); + } + + if (debugLabel.startsWith('agent-foundation-playable-dossier-batch-')) { + playableDossierBatch += 1; + return JSON.stringify({ + playableNpcs: [ + { + name: '返灯人', + title: '失职守灯人', + role: '玩家前线身份', + description: '被迫返乡的守灯人,刚站回快要熄灭的旧灯塔。', + visualDescription: '风衣上沾着盐霜,眼底始终没有睡意。', + actionDescription: '先守住灯塔,再判断该不该相信旧友。', + sceneVisualDescription: '每次钟声响起时,他都像被过去拽住。', + relationshipHooks: ['这是玩家贴近世界的第一切口'], + tags: ['玩家视角', '守灯'], + }, + ], + }); + } + + if (debugLabel.startsWith('agent-foundation-story-narrative-batch-')) { + storyNarrativeBatch += 1; + return JSON.stringify({ + storyNpcs: [ + { + name: '沈砺', + title: '旧友兼宿敌', + role: '沉船商盟引路人', + description: '他像旧友,也像最早知道假航灯秘密的人。', + visualDescription: '衣角总带着潮水味,像是刚从夜雾里走出来。', + actionDescription: '会不断试探玩家到底愿不愿意回到旧航路。', + sceneVisualDescription: '总在钟声停下后的空隙里现身。', + relationshipHooks: ['和玩家共享一段无法翻篇的旧灯塔往事'], + tags: ['旧友', '宿敌'], + }, + { + name: '岚珀', + title: '守灯会巡夜官', + role: '守灯会前台接口人', + description: '她负责把守灯会的怀疑与命令直接压到玩家面前。', + visualDescription: '披着潮湿斗篷,眼神总先看灯芯再看人。', + actionDescription: '要求玩家立刻证明自己还配站回灯塔。', + sceneVisualDescription: '总把巡夜灯举得很高,不给人躲闪空间。', + relationshipHooks: ['会逼玩家更早站队'], + tags: ['守灯会', '巡夜'], + }, + ], + }); + } + + if (debugLabel.startsWith('agent-foundation-story-dossier-batch-')) { + storyDossierBatch += 1; + return JSON.stringify({ + storyNpcs: [ + { + name: '沈砺', + title: '旧友兼宿敌', + role: '沉船商盟引路人', + description: '他像旧友,也像最早知道假航灯秘密的人。', + visualDescription: '衣角总带着潮水味,像是刚从夜雾里走出来。', + actionDescription: '会不断试探玩家到底愿不愿意回到旧航路。', + sceneVisualDescription: '总在钟声停下后的空隙里现身。', + relationshipHooks: ['和玩家共享一段无法翻篇的旧灯塔往事'], + tags: ['旧友', '宿敌'], + }, + { + name: '岚珀', + title: '守灯会巡夜官', + role: '守灯会前台接口人', + description: '她负责把守灯会的怀疑与命令直接压到玩家面前。', + visualDescription: '披着潮湿斗篷,眼神总先看灯芯再看人。', + actionDescription: '要求玩家立刻证明自己还配站回灯塔。', + sceneVisualDescription: '总把巡夜灯举得很高,不给人躲闪空间。', + relationshipHooks: ['会逼玩家更早站队'], + tags: ['守灯会', '巡夜'], + }, + ], + }); + } + + throw new Error(`未覆盖的测试 debugLabel: ${debugLabel}`); + }, + streamMessageContent: async () => { + throw new Error('这个测试不应该走流式接口'); + }, + } as UpstreamLlmClient; +} + +test('foundation draft service builds draft fields directly from framework instead of reusing preview compiler output', async () => { + const service = new CustomWorldAgentFoundationDraftService( + createFoundationDraftLlmClient(), + ); + + const draft = await service.generate({ + creatorIntent: { + sourceMode: 'freeform', + rawSettingText: '被海雾反复切开的列岛世界。', + worldHook: '旧灯塔、假航灯与失控航路重新把列岛撕开。', + themeKeywords: ['海岛', '悬疑'], + toneDirectives: ['冷峻', '潮湿'], + playerPremise: '玩家是被迫返乡的失职守灯人', + openingSituation: '开局时正站在即将熄灭的旧灯塔上', + coreConflicts: ['守灯会与沉船商盟争夺旧航路解释权'], + keyFactions: [], + keyCharacters: [], + keyLandmarks: [], + iconicElements: ['潮雾钟声', '盐火灯塔'], + forbiddenDirectives: [], + }, + anchorPack: { + creatorIntentSummary: '潮雾、旧灯塔、假航灯和被迫返乡的守灯人。', + }, + }); + + const normalized = normalizeFoundationDraftProfile(draft); + const legacyResultProfile = (draft as Record) + .legacyResultProfile as Record | undefined; + const legacyStoryNpcs = Array.isArray(legacyResultProfile?.storyNpcs) + ? (legacyResultProfile?.storyNpcs as Array>) + : []; + + assert.ok(normalized); + assert.equal(normalized?.name, '潮雾列岛'); + assert.equal( + normalized?.summary, + '潮雾列岛正在被假航灯和沉船商盟重新切开。', + ); + assert.equal(normalized?.playableNpcs.length, 1); + assert.equal(normalized?.storyNpcs.length, 2); + assert.equal(normalized?.storyNpcs[0]?.name, '沈砺'); + assert.match( + normalized?.storyNpcs[0]?.summary ?? '', + /旧友|假航灯|灯塔/u, + ); + assert.equal( + normalized?.storyNpcs[0]?.publicMask, + '衣角总带着潮水味,像是刚从夜雾里走出来。', + ); + assert.equal(normalized?.landmarks.length, 2); + assert.equal(normalized?.landmarks[0]?.name, '盐火灯塔'); + assert.equal(normalized?.sceneChapters.length, 2); + assert.equal(legacyResultProfile?.name, '潮雾列岛'); + assert.equal( + legacyResultProfile?.scenarioPackId, + 'scenario-pack:潮雾列岛', + ); + assert.equal( + legacyResultProfile?.campaignPackId, + 'campaign-pack:潮雾列岛', + ); + assert.equal(legacyStoryNpcs[0]?.name, '沈砺'); + assert.equal(legacyStoryNpcs[0]?.backstory, undefined); +}); diff --git a/server-node/src/services/customWorldAgentFoundationDraftService.ts b/server-node/src/services/customWorldAgentFoundationDraftService.ts index b13a1896..fe6b53b0 100644 --- a/server-node/src/services/customWorldAgentFoundationDraftService.ts +++ b/server-node/src/services/customWorldAgentFoundationDraftService.ts @@ -26,7 +26,6 @@ import { buildCustomWorldRoleOutlineBatchPrompt, } from '../prompts/customWorldPrompts.js'; import { - buildCompiledCustomWorldProfile, buildCustomWorldRawProfileFromFramework, type CustomWorldGenerationFramework, type CustomWorldGenerationLandmarkOutline, @@ -36,8 +35,7 @@ import { normalizeCustomWorldGenerationFramework, normalizeCustomWorldGenerationLandmarkOutlineBatch, normalizeCustomWorldGenerationRoleOutlineBatch, -} from '../modules/custom-world/runtimeProfile.js'; -import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js'; +} from '../modules/custom-world/runtime-profile/index.js'; import { buildDraftSummaryFromIntent, type CreatorCharacterSeedRecord, @@ -961,6 +959,379 @@ function buildSceneChaptersFromDraft(params: { }); } +function buildDraftFactionsFromFramework(params: { + majorFactions: string[]; + coreConflicts: string[]; + fallbackSummary: string; +}): CustomWorldFoundationDraftFaction[] { + const names = dedupeStrings(params.majorFactions, 4); + const fallbackConflict = + params.coreConflicts[0] || params.fallbackSummary || '局势仍在持续升温'; + + return names.map((name, index) => { + const relatedConflict = + params.coreConflicts[index % Math.max(1, params.coreConflicts.length)] || + fallbackConflict; + const conflictTarget = extractConflictTarget(relatedConflict); + + return { + id: createId('faction', name, index), + name, + title: name, + publicGoal: clampText( + conflictTarget + ? `拿下${conflictTarget}的主动解释权` + : '在失衡局势里先一步抢到主动权', + 28, + ), + relatedConflict, + tension: clampText(relatedConflict, 48), + playerRelation: clampText( + index === 0 + ? '它会先一步影响玩家的开局站位' + : '玩家迟早要和它发生正面碰撞', + 32, + ), + summary: clampText( + `${name}围绕“${buildCompactLabel(relatedConflict, '核心冲突', 16)}”持续施压,也会直接改变玩家的开局判断。`, + 120, + ), + } satisfies CustomWorldFoundationDraftFaction; + }); +} + +function buildDraftCharactersFromGenerationRoles(params: { + roles: CustomWorldGenerationRoleOutline[]; + roleKind: 'playable' | 'story'; + threads: CustomWorldFoundationDraftThread[]; + maxCount: number; + fallbackPressure: string; +}): CustomWorldFoundationDraftCharacter[] { + const threadCount = Math.max(1, params.threads.length); + + return params.roles.slice(0, params.maxCount).map((role, index) => { + const primaryThreadId = params.threads[index % threadCount]?.id ?? ''; + const secondaryThreadId = + params.threads[ + (index + (params.roleKind === 'playable' ? 2 : 1)) % threadCount + ]?.id ?? ''; + const fallbackRelation = + params.roleKind === 'playable' + ? '这是玩家当前最贴近世界的切入口' + : '会直接改变玩家的下一步选择'; + const publicIdentity = + clampText(role.description, 36) || '站在当前局势前台的人'; + const currentPressure = + clampText( + role.actionDescription || + role.sceneVisualDescription || + params.fallbackPressure, + 48, + ) || '正在被当前局势不断加压'; + const publicMask = clampText(role.visualDescription, 36) || undefined; + const hiddenHook = + clampText(role.sceneVisualDescription || role.tags[0] || '', 48) || + undefined; + const relationToPlayer = + clampText(role.relationshipHooks[0] || fallbackRelation, 36) || + fallbackRelation; + + return { + id: createId( + 'character', + `${params.roleKind}-${role.name || role.role || index + 1}`, + index, + ), + name: + clampText(role.name, 16) || + buildCompactLabel(role.role || role.title, '关键角色', 10), + title: clampText(role.title || role.role, 18) || '关键角色', + role: clampText(role.role || role.title, 28) || '关键角色', + publicIdentity, + publicMask, + currentPressure, + hiddenHook, + relationToPlayer, + threadIds: dedupeStrings([primaryThreadId, secondaryThreadId], 3), + summary: clampText( + [ + publicIdentity, + currentPressure ? `眼下压力是${currentPressure}` : '', + relationToPlayer ? `与玩家的关系是${relationToPlayer}` : '', + ] + .filter(Boolean) + .join(';'), + 130, + ), + } satisfies CustomWorldFoundationDraftCharacter; + }); +} + +function buildDraftLandmarksFromFramework(params: { + landmarks: CustomWorldGenerationLandmarkOutline[]; + threads: CustomWorldFoundationDraftThread[]; + storyNpcs: CustomWorldFoundationDraftCharacter[]; + maxCount: number; + fallbackConflict: string; +}): CustomWorldFoundationDraftLandmark[] { + const threadCount = Math.max(1, params.threads.length); + const storyNpcIdByName = new Map( + params.storyNpcs.map((role) => [role.name.trim(), role.id] as const), + ); + + return params.landmarks.slice(0, params.maxCount).map((landmark, index) => { + const threadIds = dedupeStrings( + [ + params.threads[index % threadCount]?.id ?? '', + params.threads[(index + 1) % threadCount]?.id ?? '', + ], + 3, + ); + const characterIds = dedupeStrings( + landmark.sceneNpcNames.map( + (name) => storyNpcIdByName.get(name.trim()) ?? '', + ), + 4, + ); + + return { + id: createId('landmark', landmark.name, index), + name: clampText(landmark.name, 16) || `关键地点${index + 1}`, + description: + clampText(landmark.visualDescription || landmark.description, 48) || + undefined, + purpose: clampText(landmark.description, 28) || '承接关键剧情推进', + mood: + clampText(landmark.visualDescription || landmark.dangerLevel, 24) || + '带着明显风险的关键地点', + importance: clampText( + `${landmark.name}和“${buildCompactLabel(params.fallbackConflict, '主线冲突', 16)}”直接勾连,第一次抵达时就会意识到它不只是背景。`, + 60, + ), + secret: + clampText(landmark.connections[0]?.summary || '', 36) || undefined, + dangerLevel: clampText(landmark.dangerLevel, 24) || undefined, + characterIds, + threadIds, + summary: clampText( + landmark.description || + landmark.visualDescription || + `${landmark.name}会把当前局势的压力直接抬到台前。`, + 120, + ), + } satisfies CustomWorldFoundationDraftLandmark; + }); +} + +/** + * 工作包 G 的最小收口实现: + * foundation draft 主字段直接由 framework / role detail / landmark detail 组装, + * 不再通过 preview compiler 先转成 legacy runtime profile 再反解回 draft。 + */ +function buildFoundationDraftProfileFromFramework(params: { + framework: CustomWorldGenerationFramework; + playableDetailed: CustomWorldGenerationRoleOutline[]; + storyDetailed: CustomWorldGenerationRoleOutline[]; + creatorIntent: CustomWorldCreatorIntentRecord; + anchorPack: unknown; + anchorContent?: EightAnchorContent | null; + settingText: string; +}) { + const normalizedAnchorContent = normalizeEightAnchorContent( + params.anchorContent, + ); + const coreConflicts = + dedupeStrings(params.framework.coreConflicts, 4).length > 0 + ? dedupeStrings(params.framework.coreConflicts, 4) + : dedupeStrings(params.creatorIntent.coreConflicts, 4).length > 0 + ? dedupeStrings(params.creatorIntent.coreConflicts, 4) + : [params.framework.summary || '旧秩序与新力量正在争夺这个世界的解释权']; + const iconicElements = dedupeStrings(params.creatorIntent.iconicElements, 6); + const playerPremise = + clampText(params.creatorIntent.playerPremise, 72) || + '玩家是一名被卷进局势中心的行动者'; + const openingSituation = + clampText(params.creatorIntent.openingSituation, 72) || + '故事开局时,玩家已经站在必须立刻选边的位置上'; + const worldHook = + clampText( + params.creatorIntent.worldHook || + params.creatorIntent.rawSettingText || + params.framework.summary, + 72, + ) || '一个仍在失衡边缘不断扩张的世界'; + const fallbackFactions = buildFactions({ + intent: params.creatorIntent, + coreConflicts, + playerPremise, + iconicElements, + }); + const factions = + dedupeStrings(params.framework.majorFactions, 4).length > 0 + ? buildDraftFactionsFromFramework({ + majorFactions: params.framework.majorFactions, + coreConflicts, + fallbackSummary: params.framework.summary, + }) + : fallbackFactions; + const baseThreads = buildBaseThreads({ + intent: params.creatorIntent, + coreConflicts, + playerPremise, + openingSituation, + iconicElements, + }); + const playableNpcs = buildDraftCharactersFromGenerationRoles({ + roles: params.playableDetailed, + roleKind: 'playable', + threads: baseThreads, + maxCount: FOUNDATION_DRAFT_PLAYABLE_COUNT, + fallbackPressure: coreConflicts[0] || params.framework.summary, + }); + const storyNpcs = buildDraftCharactersFromGenerationRoles({ + roles: params.storyDetailed, + roleKind: 'story', + threads: baseThreads, + maxCount: FOUNDATION_DRAFT_STORY_COUNT, + fallbackPressure: coreConflicts[0] || params.framework.summary, + }); + const landmarks = buildDraftLandmarksFromFramework({ + landmarks: params.framework.landmarks, + threads: baseThreads, + storyNpcs, + maxCount: FOUNDATION_DRAFT_LANDMARK_COUNT, + fallbackConflict: coreConflicts[0] || params.framework.summary, + }); + const threads = finalizeThreads({ + threads: baseThreads.slice(0, 4), + characters: [...playableNpcs, ...storyNpcs], + landmarks, + }); + const chapter = buildChapter({ + worldName: params.framework.name, + openingSituation, + playerGoal: params.framework.playerGoal, + characters: [...playableNpcs, ...storyNpcs], + landmarks, + threads, + }); + const sceneChapters = buildSceneChaptersFromDraft({ + landmarks, + playableNpcs, + storyNpcs, + threads, + }); + + const legacyFramework: CustomWorldGenerationFramework = { + ...params.framework, + playableNpcs: params.playableDetailed, + storyNpcs: params.storyDetailed, + }; + const legacyResultProfile = buildCustomWorldRawProfileFromFramework( + legacyFramework, + ) as Record; + legacyResultProfile.id = + legacyResultProfile.id ?? + `agent-draft-${slugify(params.framework.name || 'world')}`; + legacyResultProfile.settingText = params.settingText; + legacyResultProfile.sceneChapterBlueprints = sceneChapters; + legacyResultProfile.generationMode = 'fast'; + legacyResultProfile.generationStatus = 'key_only'; + legacyResultProfile.scenarioPackId = + legacyResultProfile.scenarioPackId ?? + `scenario-pack:${slugify(params.framework.name || 'world')}`; + legacyResultProfile.campaignPackId = + legacyResultProfile.campaignPackId ?? + `campaign-pack:${slugify(params.framework.name || 'world')}`; + legacyResultProfile.creatorIntent = + legacyResultProfile.creatorIntent ?? + (params.creatorIntent as unknown as Record); + legacyResultProfile.anchorPack = + legacyResultProfile.anchorPack ?? + (toRecord(params.anchorPack) ?? + ({ value: params.anchorPack } as Record)); + if (normalizedAnchorContent) { + legacyResultProfile.anchorContent = + legacyResultProfile.anchorContent ?? + (normalizedAnchorContent as unknown as Record); + } + + return { + name: clampText(params.framework.name, 40) || '未命名世界底稿', + subtitle: + clampText(params.framework.subtitle, 40) || + clampText( + [ + buildCompactLabel(playerPremise, '玩家视角', 12), + buildCompactLabel(coreConflicts[0] || '', '核心冲突', 16), + ] + .filter(Boolean) + .join(' · '), + 40, + ) || + '第一版世界底稿', + summary: + clampText(params.framework.summary, 180) || + clampText( + `${worldHook} 玩家会以“${playerPremise}”切入这个世界,眼下最直接的冲突是“${coreConflicts[0]}”。`, + 180, + ) || + '第一版世界底稿已经整理完成。', + tone: + clampText(params.framework.tone, 72) || + buildTone(params.creatorIntent), + playerGoal: + clampText(params.framework.playerGoal, 72) || + buildPlayerGoal({ + playerPremise, + openingSituation, + coreConflict: coreConflicts[0] || '', + }), + majorFactions: + dedupeStrings(params.framework.majorFactions, 6).length > 0 + ? dedupeStrings(params.framework.majorFactions, 6) + : factions.map((entry) => entry.name), + coreConflicts, + playableNpcs, + storyNpcs, + landmarks, + camp: { + id: 'camp-home', + name: clampText(params.framework.camp.name, 16) || '开局据点', + description: + clampText(params.framework.camp.description, 72) || + '玩家暂时还能整顿情报、换口气并决定下一步站位的落脚处。', + mood: + clampText(params.framework.tone, 36) || '紧绷但还可暂时收住局势', + dangerLevel: + clampText(params.framework.camp.dangerLevel, 24) || undefined, + summary: clampText( + params.framework.camp.description || + `${params.framework.camp.name}仍是玩家在风暴边缘还能勉强站稳的一块地方。`, + 88, + ), + } satisfies CustomWorldFoundationDraftCamp, + themePack: null, + storyGraph: null, + factions, + threads, + chapters: [chapter], + sceneChapters, + worldHook, + playerPremise, + openingSituation, + iconicElements, + sourceAnchorSummary: + buildDraftSummaryFromEightAnchorContent(normalizedAnchorContent) || + toText(toRecord(params.anchorPack)?.creatorIntentSummary) || + buildDraftSummaryFromIntent(params.creatorIntent) || + params.framework.summary, + legacyResultProfile, + } satisfies CustomWorldFoundationDraftProfile & { + legacyResultProfile: Record; + }; +} + function getNamedRecordKey(value: unknown) { return toText(value).replace(/\s+/gu, ''); } @@ -1496,271 +1867,6 @@ async function expandFoundationRoleEntries(params: { return mergedEntries; } -function buildDraftFactionsFromRuntimeProfile(profile: CustomWorldProfile) { - const factionNames = dedupeStrings(profile.majorFactions, 4); - const firstConflict = profile.coreConflicts[0] || profile.summary; - - return factionNames.slice(0, 4).map((name, index) => { - const relatedConflict = - profile.coreConflicts[ - index % Math.max(1, profile.coreConflicts.length) - ] || firstConflict; - return { - id: createId('faction', name, index), - name, - title: name, - publicGoal: clampText( - extractConflictTarget(relatedConflict) - ? `拿下${extractConflictTarget(relatedConflict)}的主导权` - : '在失衡局势里先抢到主动权', - 28, - ), - relatedConflict, - tension: clampText(relatedConflict, 48), - playerRelation: clampText( - index === 0 - ? '它会主动影响玩家的第一步站位' - : '玩家迟早要和它发生直接交集', - 32, - ), - summary: clampText( - `${name}围绕“${buildCompactLabel(relatedConflict, '核心冲突', 16)}”持续施压,也会直接影响玩家的开局判断。`, - 120, - ), - } satisfies CustomWorldFoundationDraftFaction; - }); -} - -function buildDraftThreadsFromRuntimeProfile( - profile: CustomWorldProfile, -): CustomWorldFoundationDraftThread[] { - const graphThreads = [ - ...(profile.storyGraph?.visibleThreads ?? []).slice(0, 2), - ...(profile.storyGraph?.hiddenThreads ?? []).slice(0, 2), - ]; - - if (graphThreads.length > 0) { - return graphThreads.map( - (thread, index) => - ({ - id: thread.id || createId('thread', thread.title, index), - title: clampText(thread.title, 18), - type: thread.visibility === 'hidden' ? 'hidden' : 'main', - conflictType: clampText(thread.conflictType, 18), - conflict: clampText(thread.summary || thread.stakes, 72), - stakes: clampText(thread.stakes, 48), - characterIds: thread.involvedActorIds.slice(0, 4), - landmarkIds: thread.relatedLocationIds.slice(0, 4), - summary: clampText(thread.summary, 120), - }) satisfies CustomWorldFoundationDraftThread, - ); - } - - return profile.coreConflicts.slice(0, 3).map((conflict, index) => ({ - id: createId('thread', conflict, index), - title: buildCompactLabel(conflict, `主线${index + 1}`, 16), - type: index === 1 ? 'hidden' : 'main', - conflict, - characterIds: [], - landmarkIds: [], - summary: clampText(`这条线围绕“${conflict}”持续推进。`, 80), - })); -} - -function buildDraftCharactersFromRuntimeProfile( - roles: CustomWorldProfile['playableNpcs'] | CustomWorldProfile['storyNpcs'], - fallbackThreadIds: string[], -) { - return roles.map((role) => ({ - id: role.id, - name: role.name, - title: clampText(role.title || role.role, 18) || '关键角色', - role: clampText(role.role || role.title, 28) || '关键角色', - publicIdentity: - clampText( - role.narrativeProfile?.publicMask || - role.backstoryReveal.publicSummary || - role.description, - 36, - ) || '站在局势前台的人', - publicMask: - clampText( - role.narrativeProfile?.firstContactMask || role.personality, - 36, - ) || undefined, - currentPressure: - clampText( - role.narrativeProfile?.immediatePressure || - role.motivation || - role.backstory, - 48, - ) || '正在被当前局势不断加压', - hiddenHook: - clampText( - role.narrativeProfile?.hiddenLine || - role.backstoryReveal.chapters[2]?.content || - role.backstory, - 48, - ) || undefined, - relationToPlayer: - clampText( - role.relationshipHooks[0] || - role.narrativeProfile?.visibleLine || - role.motivation, - 36, - ) || '会直接改变玩家的下一步选择', - threadIds: - role.narrativeProfile?.relatedThreadIds?.slice(0, 3) ?? - fallbackThreadIds.slice(0, 3), - summary: - clampText(role.description || role.backstoryReveal.publicSummary, 120) || - '这个角色会持续推动当前世界底稿继续展开。', - })) satisfies CustomWorldFoundationDraftCharacter[]; -} - -function buildDraftLandmarksFromRuntimeProfile( - profile: CustomWorldProfile, - threads: CustomWorldFoundationDraftThread[], -) { - return profile.landmarks - .slice(0, FOUNDATION_DRAFT_LANDMARK_COUNT) - .map((landmark) => { - const relatedThreadIds = threads - .filter((thread) => thread.landmarkIds.includes(landmark.id)) - .map((thread) => thread.id) - .slice(0, 3); - - return { - id: landmark.id, - name: landmark.name, - description: clampText(landmark.description, 48) || undefined, - purpose: clampText(landmark.description, 28) || '承接关键剧情推进', - mood: - clampText( - landmark.narrativeResidues?.[0]?.summary || - landmark.dangerLevel || - '带着明显风险的关键地点', - 24, - ) || '带着明显风险的关键地点', - importance: clampText( - landmark.narrativeResidues?.[0]?.changeHint || - landmark.description || - '和当前主线冲突直接勾连的关键地点', - 60, - ), - secret: - clampText( - landmark.narrativeResidues?.[0]?.hiddenTruth || - landmark.connections[0]?.summary || - '', - 36, - ) || undefined, - dangerLevel: landmark.dangerLevel, - characterIds: landmark.sceneNpcIds.slice(0, 4), - threadIds: relatedThreadIds, - summary: clampText( - landmark.description || - landmark.narrativeResidues?.[0]?.summary || - '', - 120, - ), - } satisfies CustomWorldFoundationDraftLandmark; - }); -} - -function convertRuntimeProfileToFoundationDraft(params: { - profile: CustomWorldProfile; - intent: CustomWorldCreatorIntentRecord; - anchorPack: unknown; -}) { - const factions = buildDraftFactionsFromRuntimeProfile(params.profile); - const threads = buildDraftThreadsFromRuntimeProfile(params.profile); - const playableNpcs = buildDraftCharactersFromRuntimeProfile( - params.profile.playableNpcs.slice(0, FOUNDATION_DRAFT_PLAYABLE_COUNT), - threads.slice(0, 2).map((entry) => entry.id), - ); - const storyNpcs = buildDraftCharactersFromRuntimeProfile( - params.profile.storyNpcs.slice(0, FOUNDATION_DRAFT_STORY_COUNT), - threads.slice(1, 3).map((entry) => entry.id), - ); - const landmarks = buildDraftLandmarksFromRuntimeProfile( - params.profile, - threads, - ); - const chapter = buildChapter({ - worldName: params.profile.name, - openingSituation: - clampText(params.intent.openingSituation, 60) || params.profile.summary, - playerGoal: params.profile.playerGoal, - characters: [...playableNpcs, ...storyNpcs], - landmarks, - threads, - }); - const sceneChapters = buildSceneChaptersFromDraft({ - landmarks, - playableNpcs, - storyNpcs, - threads, - }); - const anchorRecord = toRecord(params.anchorPack); - - return { - name: params.profile.name, - subtitle: params.profile.subtitle, - summary: params.profile.summary, - tone: params.profile.tone, - playerGoal: params.profile.playerGoal, - majorFactions: - params.profile.majorFactions.length > 0 - ? params.profile.majorFactions - : factions.map((entry) => entry.name), - coreConflicts: - params.profile.coreConflicts.length > 0 - ? params.profile.coreConflicts - : [params.profile.summary], - playableNpcs, - storyNpcs, - landmarks, - camp: params.profile.camp - ? ({ - id: 'camp-home', - name: params.profile.camp.name, - description: params.profile.camp.description, - mood: clampText(params.profile.tone, 36) || '紧绷但还可暂时收住局势', - dangerLevel: params.profile.camp.dangerLevel, - summary: clampText(params.profile.camp.description, 88), - } satisfies CustomWorldFoundationDraftCamp) - : null, - themePack: - (params.profile.themePack as unknown as Record | null) ?? - null, - storyGraph: - (params.profile.storyGraph as unknown as Record | null) ?? - null, - factions, - threads, - chapters: [chapter], - sceneChapters, - worldHook: - clampText(params.intent.worldHook || params.profile.summary, 72) || - params.profile.summary, - playerPremise: - clampText(params.intent.playerPremise, 72) || - '玩家是一名被卷进局势中心的行动者', - openingSituation: - clampText(params.intent.openingSituation, 72) || - '故事开局时,玩家已经站在必须立刻选边的位置上', - iconicElements: dedupeStrings(params.intent.iconicElements, 6), - sourceAnchorSummary: - toText(anchorRecord?.creatorIntentSummary) || - buildDraftSummaryFromIntent(params.intent) || - params.profile.summary, - legacyResultProfile: params.profile as unknown as Record, - } satisfies CustomWorldFoundationDraftProfile & { - legacyResultProfile: Record; - }; -} - async function buildFoundationDraftProfileWithLlm(params: { llmClient: UpstreamLlmClient; creatorIntent: CustomWorldCreatorIntentRecord; @@ -1885,25 +1991,19 @@ async function buildFoundationDraftProfileWithLlm(params: { await emitDraftProgress(params.onProgress, { phaseLabel: '编译世界底稿', - phaseDetail: '正在把分批生成结果整理成旧版世界结果结构,再编成草稿卡底稿。', + phaseDetail: + '正在把分批生成结果直接整理成第一版 foundation draft,并同步兼容结果快照。', progress: 97, }); - const rawProfile = buildCustomWorldRawProfileFromFramework( + return buildFoundationDraftProfileFromFramework({ framework, - ) as Record; - rawProfile.playableNpcs = playableDetailed; - rawProfile.storyNpcs = storyDetailed; - rawProfile.landmarks = framework.landmarks; - - const runtimeProfile = buildCompiledCustomWorldProfile( - rawProfile, - settingText, - ); - return convertRuntimeProfileToFoundationDraft({ - profile: runtimeProfile, - intent: params.creatorIntent, + playableDetailed, + storyDetailed, + creatorIntent: params.creatorIntent, anchorPack: params.anchorPack, + anchorContent: params.anchorContent, + settingText, }); } diff --git a/server-node/src/services/customWorldAgentMessageTurnService.ts b/server-node/src/services/customWorldAgentMessageTurnService.ts new file mode 100644 index 00000000..bf2843e4 --- /dev/null +++ b/server-node/src/services/customWorldAgentMessageTurnService.ts @@ -0,0 +1,196 @@ +import crypto from 'node:crypto'; + +import type { + CreatorIntentReadiness, + CustomWorldAgentMessage, + CustomWorldAgentSessionSnapshot, +} from '../../../packages/shared/src/contracts/customWorldAgent.js'; +import { + buildPendingClarifications, + evaluateCreatorIntentReadiness, + resolveCreatorIntentStage, +} from './customWorldAgentClarificationService.js'; +import { + buildAnchorPackFromIntent, + buildDraftSummaryFromIntent, + buildDraftTitleFromIntent, + type CustomWorldCreatorIntentRecord, +} from './customWorldAgentIntentExtractionService.js'; +import type { + CustomWorldAgentSessionRecord, + CustomWorldAgentSessionStore, +} from './customWorldAgentSessionStore.js'; +import type { CustomWorldAgentSuggestedActionService } from './customWorldAgentSuggestedActionService.js'; +import { CustomWorldAgentSnapshotBuilder } from './customWorldAgentSnapshotBuilder.js'; +import { + buildCreatorIntentFromEightAnchorContent, + buildEightAnchorContentFromCreatorIntent, +} from './eightAnchorCompatibilityService.js'; +import { EightAnchorSingleTurnService } from './eightAnchorSingleTurnService.js'; + +function buildDerivedState( + intent: CustomWorldCreatorIntentRecord, + hasUserInput: boolean, + suggestedActionService: CustomWorldAgentSuggestedActionService, +) { + const readiness = evaluateCreatorIntentReadiness(intent); + const pendingClarifications = buildPendingClarifications(intent, readiness); + const stage = resolveCreatorIntentStage({ + hasUserInput, + readiness, + }); + + return { + readiness, + pendingClarifications, + stage, + anchorPack: buildAnchorPackFromIntent(intent, { + completedKeys: readiness.completedKeys, + missingKeys: readiness.missingKeys, + }), + draftProfile: { + title: buildDraftTitleFromIntent(intent), + summary: buildDraftSummaryFromIntent(intent), + }, + suggestedActions: suggestedActionService.buildSuggestedActions({ + stage, + isReady: readiness.isReady, + }), + }; +} + +export class CustomWorldAgentMessageTurnService { + constructor( + private readonly sessionStore: CustomWorldAgentSessionStore, + private readonly eightAnchorSingleTurnService: EightAnchorSingleTurnService, + private readonly suggestedActionService: CustomWorldAgentSuggestedActionService, + private readonly snapshotBuilder: CustomWorldAgentSnapshotBuilder, + ) {} + + async applyMessageTurn(params: { + userId: string; + sessionId: string; + latestUserText: string; + quickFillRequested: boolean; + relatedOperationId?: string | null; + onReplyUpdate?: (text: string) => void; + }) { + const latestSession = (await this.sessionStore.get( + params.userId, + params.sessionId, + )) as CustomWorldAgentSessionRecord | null; + if (!latestSession) { + throw new Error('custom world agent session not found'); + } + + const shouldPreserveDraftStage = + (latestSession.stage === 'object_refining' || + latestSession.stage === 'visual_refining') && + latestSession.draftCards.length > 0; + + const assistantTurn = await this.eightAnchorSingleTurnService.streamTurn( + { + currentTurn: latestSession.currentTurn + 1, + progressPercent: latestSession.progressPercent, + quickFillRequested: params.quickFillRequested, + currentAnchorContent: latestSession.anchorContent, + chatHistory: latestSession.messages + .filter( + (message): message is CustomWorldAgentMessage => + (message.role === 'user' || message.role === 'assistant') && + Boolean(message.text.trim()), + ) + .map((message) => ({ + role: message.role, + content: message.text, + })), + }, + { + onReplyUpdate: params.onReplyUpdate, + }, + ); + const nextCreatorIntent = buildCreatorIntentFromEightAnchorContent( + assistantTurn.nextAnchorContent, + ); + const progressPercent = Math.max( + 0, + Math.min(100, Math.round(assistantTurn.progressPercent)), + ); + const creatorIntentReadiness: CreatorIntentReadiness = + progressPercent >= 100 + ? { + isReady: true, + completedKeys: [ + 'world_hook', + 'player_premise', + 'theme_and_tone', + 'core_conflict', + 'relationship_seed', + 'iconic_element', + ], + missingKeys: [], + } + : evaluateCreatorIntentReadiness(nextCreatorIntent); + const derivedState = buildDerivedState( + nextCreatorIntent, + true, + this.suggestedActionService, + ); + const shouldStayInDraftStage = + shouldPreserveDraftStage && progressPercent >= 100; + const assistantMessage = { + id: `message-${crypto.randomBytes(8).toString('hex')}`, + role: 'assistant', + kind: 'chat', + text: assistantTurn.replyText, + createdAt: new Date().toISOString(), + relatedOperationId: params.relatedOperationId ?? null, + } satisfies CustomWorldAgentMessage; + + await this.sessionStore.replaceDerivedState( + params.userId, + params.sessionId, + this.snapshotBuilder.buildMessageTurnState({ + latestSession, + nextAnchorContent: assistantTurn.nextAnchorContent, + progressPercent, + replyText: assistantTurn.replyText, + nextCreatorIntent, + creatorIntentReadiness, + derivedDraftProfile: derivedState.draftProfile, + derivedPendingClarifications: derivedState.pendingClarifications, + derivedStage: derivedState.stage, + shouldStayInDraftStage, + }), + ); + await this.sessionStore.appendMessage( + params.userId, + params.sessionId, + assistantMessage, + ); + + return (await this.sessionStore.getSnapshot( + params.userId, + params.sessionId, + )) as CustomWorldAgentSessionSnapshot; + } + + deriveInitialSessionState(params: { + seedText: string; + creatorIntent: CustomWorldCreatorIntentRecord; + }) { + const anchorContent = buildEightAnchorContentFromCreatorIntent( + params.creatorIntent, + ); + const derivedState = buildDerivedState( + params.creatorIntent, + Boolean(params.seedText), + this.suggestedActionService, + ); + + return { + anchorContent, + ...derivedState, + }; + } +} diff --git a/server-node/src/services/customWorldAgentOrchestrator.ts b/server-node/src/services/customWorldAgentOrchestrator.ts index 484e2320..6910d45b 100644 --- a/server-node/src/services/customWorldAgentOrchestrator.ts +++ b/server-node/src/services/customWorldAgentOrchestrator.ts @@ -8,59 +8,50 @@ import type { CustomWorldAgentMessage, CustomWorldAgentOperationRecord, CustomWorldAgentSessionSnapshot, - CustomWorldDraftCardSummary, CustomWorldPendingClarification, - CustomWorldSuggestedAction, SendCustomWorldAgentMessageRequest, SendCustomWorldAgentMessageResponse, } from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import { badRequest, notFound } from '../errors.js'; +import { notFound } from '../errors.js'; import { prepareEventStreamResponse } from '../http.js'; -import { normalizeCustomWorldProfile } from '../modules/custom-world/runtimeProfile.js'; -import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js'; +import { CustomWorldAgentActionRegistry } from './customWorldAgentActionRegistry.js'; +import { createCustomWorldAgentActionExecutorMap } from './customWorldAgentActionExecutors/index.js'; import { CustomWorldAgentAssetBridgeService } from './customWorldAgentAssetBridgeService.js'; import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js'; import { CustomWorldAgentChangeSummaryService } from './customWorldAgentChangeSummaryService.js'; -import { - buildPendingClarifications, - evaluateCreatorIntentReadiness, - resolveCreatorIntentStage, -} from './customWorldAgentClarificationService.js'; +import { CustomWorldAgentMessageTurnService } from './customWorldAgentMessageTurnService.js'; import { CustomWorldAgentDraftCompiler, - getWorldFoundationCardId, normalizeFoundationDraftProfile, } from './customWorldAgentDraftCompiler.js'; -import { updateDraftCardSections } from './customWorldAgentDraftEditService.js'; import { CustomWorldAgentEntityGenerationService } from './customWorldAgentEntityGenerationService.js'; import { CustomWorldAgentFoundationDraftService } from './customWorldAgentFoundationDraftService.js'; import { - buildAnchorPackFromIntent, - buildDraftSummaryFromIntent, - buildDraftTitleFromIntent, createEmptyCreatorIntentRecord, type CustomWorldCreatorIntentRecord, extractCreatorIntentPatch, hasMeaningfulCreatorIntentRecord, mergeCreatorIntentRecord, - normalizeCreatorIntentRecord, } from './customWorldAgentIntentExtractionService.js'; -import { - rebuildRoleAssetCoverage, - resolveRoleAssetStatusLabel, -} from './customWorldAgentRoleAssetStateService.js'; +import { CustomWorldAgentPublishingService } from './customWorldAgentPublishingService.js'; +import { CustomWorldAgentQualityGateService } from './customWorldAgentQualityGateService.js'; +import { CustomWorldAgentResultSyncService } from './customWorldAgentResultSyncService.js'; +import { CustomWorldAgentSnapshotBuilder } from './customWorldAgentSnapshotBuilder.js'; import { type CustomWorldAgentSessionRecord, CustomWorldAgentSessionStore, } from './customWorldAgentSessionStore.js'; +import { CustomWorldAgentSuggestedActionService } from './customWorldAgentSuggestedActionService.js'; import { - buildAnchorPackFromEightAnchorContent, - buildCreatorIntentFromEightAnchorContent, buildEightAnchorContentFromCreatorIntent, estimateProgressPercentFromAnchorContent, } from './eightAnchorCompatibilityService.js'; import { EightAnchorSingleTurnService } from './eightAnchorSingleTurnService.js'; +import { buildRpgWorldPreviewEnvelope } from './RpgWorldPreviewCompiler.js'; +import { buildRpgCreationPreviewProfileFromDraftProfile } from './rpgCreationPreviewProfileBuilder.js'; import type { UpstreamLlmClient } from './llmClient.js'; +import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js'; +import type { UserRepositoryPort } from '../repositories/userRepository.js'; const PHASE2_FORCE_FAIL_TOKEN = '__phase1_force_fail__'; function truncateText(value: string, maxLength: number) { @@ -77,75 +68,6 @@ function sleep(ms: number) { }); } -function buildSuggestedActions( - params: { - stage?: CustomWorldAgentSessionRecord['stage']; - isReady?: boolean; - draftProfile?: unknown; - draftCards?: CustomWorldDraftCardSummary[]; - } = {}, -): CustomWorldSuggestedAction[] { - const profile = normalizeFoundationDraftProfile(params.draftProfile); - const actions: CustomWorldSuggestedAction[] = [ - { - id: 'request_summary', - type: 'request_summary', - label: - params.stage === 'object_refining' || params.stage === 'visual_refining' - ? '总结当前世界底稿' - : '总结当前设定', - }, - ]; - - if (params.stage === 'foundation_review' && params.isReady) { - actions.push({ - id: 'draft_foundation', - type: 'draft_foundation', - label: '整理一版世界底稿', - }); - return actions; - } - - if ( - (params.stage === 'object_refining' || - params.stage === 'visual_refining') && - profile - ) { - const worldCardId = - params.draftCards?.find((entry) => entry.kind === 'world')?.id ?? - getWorldFoundationCardId(); - const firstCharacter = [...profile.playableNpcs, ...profile.storyNpcs][0]; - const firstLandmark = profile.landmarks[0]; - - actions.push({ - id: 'refine_world', - type: 'refine_focus_target', - label: '先看世界总卡', - targetId: worldCardId, - }); - - if (firstCharacter) { - actions.push({ - id: `refine-character-${firstCharacter.id}`, - type: 'refine_focus_target', - label: `精修角色:${firstCharacter.name}`, - targetId: firstCharacter.id, - }); - } - - if (firstLandmark) { - actions.push({ - id: `refine-landmark-${firstLandmark.id}`, - type: 'refine_focus_target', - label: `继续补地点:${firstLandmark.name}`, - targetId: firstLandmark.id, - }); - } - } - - return actions; -} - function buildOperation(type: CustomWorldAgentOperationRecord['type']) { const phaseDetail = type === 'draft_foundation' @@ -198,153 +120,6 @@ function buildRoleAssetSyncResultText(params: { return `已把「${params.roleName}」的角色资产写回草稿,当前状态:${params.assetStatusLabel}。`; } -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function toRecordArray(value: unknown) { - return Array.isArray(value) - ? value.filter((item): item is Record => isRecord(item)) - : []; -} - -function cloneJsonRecord(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - -function syncRoleAssetsFromResultProfile(params: { - currentRoles: unknown; - resultRoles: unknown; -}) { - const resultRoleById = new Map( - toRecordArray(params.resultRoles).map((role) => [toText(role.id), role]), - ); - - return toRecordArray(params.currentRoles).map((currentRole) => { - const resultRole = resultRoleById.get(toText(currentRole.id)); - if (!resultRole) { - return currentRole; - } - - return { - ...currentRole, - imageSrc: toText(resultRole.imageSrc) || null, - generatedVisualAssetId: toText(resultRole.generatedVisualAssetId) || null, - generatedAnimationSetId: - toText(resultRole.generatedAnimationSetId) || null, - animationMap: isRecord(resultRole.animationMap) - ? cloneJsonRecord(resultRole.animationMap) - : null, - } satisfies Record; - }); -} - -function syncLandmarkAssetsFromResultProfile(params: { - currentLandmarks: unknown; - resultLandmarks: unknown; -}) { - const resultLandmarkById = new Map( - toRecordArray(params.resultLandmarks).map((landmark) => [ - toText(landmark.id), - landmark, - ]), - ); - - return toRecordArray(params.currentLandmarks).map((currentLandmark) => { - const resultLandmark = resultLandmarkById.get(toText(currentLandmark.id)); - if (!resultLandmark) { - return currentLandmark; - } - - return { - ...currentLandmark, - imageSrc: toText(resultLandmark.imageSrc) || null, - } satisfies Record; - }); -} - -function syncSceneChapterAssetsFromResultProfile(params: { - currentSceneChapters: unknown; - resultSceneChapters: unknown; -}) { - const resultSceneChapterBySceneId = new Map( - toRecordArray(params.resultSceneChapters).map((chapter) => [ - toText(chapter.sceneId), - chapter, - ]), - ); - - return toRecordArray(params.currentSceneChapters).map((currentChapter) => { - const resultChapter = resultSceneChapterBySceneId.get( - toText(currentChapter.sceneId), - ); - if (!resultChapter) { - return currentChapter; - } - - const resultActById = new Map( - toRecordArray(resultChapter.acts).map((act) => [toText(act.id), act]), - ); - - return { - ...currentChapter, - acts: toRecordArray(currentChapter.acts).map((currentAct) => { - const resultAct = resultActById.get(toText(currentAct.id)); - if (!resultAct) { - return currentAct; - } - - return { - ...currentAct, - backgroundImageSrc: toText(resultAct.backgroundImageSrc) || null, - backgroundAssetId: toText(resultAct.backgroundAssetId) || null, - } satisfies Record; - }), - } satisfies Record; - }); -} - -function syncResultProfileIntoDraftProfile(params: { - currentDraftProfile: Record | null | undefined; - resultProfile: CustomWorldProfile; -}) { - const currentDraftProfile = params.currentDraftProfile ?? {}; - const resultProfile = params.resultProfile; - - return { - // 阶段一只回写基础摘要和完整 legacy 快照,避免把结果页的运行时结构反向拆回 foundation draft。 - ...currentDraftProfile, - name: resultProfile.name, - subtitle: resultProfile.subtitle, - summary: resultProfile.summary, - tone: resultProfile.tone, - playerGoal: resultProfile.playerGoal, - majorFactions: resultProfile.majorFactions, - coreConflicts: resultProfile.coreConflicts, - playableNpcs: syncRoleAssetsFromResultProfile({ - currentRoles: currentDraftProfile.playableNpcs, - resultRoles: resultProfile.playableNpcs, - }), - storyNpcs: syncRoleAssetsFromResultProfile({ - currentRoles: currentDraftProfile.storyNpcs, - resultRoles: resultProfile.storyNpcs, - }), - landmarks: syncLandmarkAssetsFromResultProfile({ - currentLandmarks: currentDraftProfile.landmarks, - resultLandmarks: resultProfile.landmarks, - }), - sceneChapters: syncSceneChapterAssetsFromResultProfile({ - currentSceneChapters: currentDraftProfile.sceneChapters, - resultSceneChapters: resultProfile.sceneChapterBlueprints, - }), - legacyResultProfile: resultProfile as unknown as Record, - } satisfies Record; -} - function buildQuestionLines( pendingClarifications: CustomWorldPendingClarification[], ) { @@ -367,36 +142,6 @@ function composeAssistantReply(params: { ].join('\n'); } -function buildDerivedState( - intent: CustomWorldCreatorIntentRecord, - hasUserInput: boolean, -) { - const readiness = evaluateCreatorIntentReadiness(intent); - const pendingClarifications = buildPendingClarifications(intent, readiness); - const stage = resolveCreatorIntentStage({ - hasUserInput, - readiness, - }); - - return { - readiness, - pendingClarifications, - stage, - anchorPack: buildAnchorPackFromIntent(intent, { - completedKeys: readiness.completedKeys, - missingKeys: readiness.missingKeys, - }), - draftProfile: { - title: buildDraftTitleFromIntent(intent), - summary: buildDraftSummaryFromIntent(intent), - }, - suggestedActions: buildSuggestedActions({ - stage, - isReady: readiness.isReady, - }), - }; -} - function buildWelcomeMessage(params: { seedText: string; intent: CustomWorldCreatorIntentRecord; @@ -423,51 +168,6 @@ function buildWelcomeMessage(params: { }); } -function buildFoundationDraftAssistantMessage(params: { - relatedOperationId: string; - draftProfile: unknown; - warnings?: string[]; -}) { - const profile = normalizeFoundationDraftProfile(params.draftProfile); - const leadCharacter = profile?.playableNpcs[0]; - const leadLandmark = profile?.landmarks[0]; - const warnings = (params.warnings ?? []).filter(Boolean); - - return { - id: `message-${crypto.randomBytes(8).toString('hex')}`, - role: 'assistant', - kind: 'summary', - text: [ - `我先把第一版世界底稿整理出来了:${profile?.summary || '底稿已经生成完成。'}`, - '', - `当前已经落下来的第一批对象数量是:关键角色 ${profile?.playableNpcs.length ?? 0} 个,关键地点 ${profile?.landmarks.length ?? 0} 个,势力 ${profile?.factions.length ?? 0} 个。`, - `建议你先从“${profile?.name || '世界总卡'}”这张世界总卡看起${leadCharacter ? `,再顺着角色「${leadCharacter.name}」往下细修` : ''}${leadLandmark ? `,地点可以先看「${leadLandmark.name}」` : ''}。`, - ...(warnings.length > 0 - ? [ - '', - `这一轮有 ${warnings.length} 项资产补齐未完成,但不影响世界底稿继续精修。`, - ] - : []), - ].join('\n'), - createdAt: new Date().toISOString(), - relatedOperationId: params.relatedOperationId, - } satisfies CustomWorldAgentMessage; -} - -function buildActionResultMessage(params: { - relatedOperationId: string; - text: string; -}) { - return { - id: `message-${crypto.randomBytes(8).toString('hex')}`, - role: 'assistant', - kind: 'action_result', - text: params.text, - createdAt: new Date().toISOString(), - relatedOperationId: params.relatedOperationId, - } satisfies CustomWorldAgentMessage; -} - function writeSseEvent( response: Response, event: string, @@ -481,6 +181,27 @@ function writeSseEvent( response.write(`data: ${JSON.stringify(data)}\n\n`); } +/** + * 发布 readiness 校验和正式写库复用同一个服务。 + * 当运行环境还没接入真实作品仓储时,真正执行发布动作会在这里统一抛出明确错误。 + */ +function createUnavailablePublishingRepository(): RpgWorldProfileRepositoryPort { + const throwUnavailable = async () => { + throw new Error('当前环境还没有注入发布仓储,暂时无法执行世界发布。'); + }; + + return { + listOwnProfiles: throwUnavailable, + upsertOwnProfile: throwUnavailable, + syncProfileFromSnapshot: throwUnavailable, + softDeleteOwnProfile: throwUnavailable, + publishOwnProfile: throwUnavailable, + unpublishOwnProfile: throwUnavailable, + listPublishedGallery: throwUnavailable, + getPublishedGalleryDetail: throwUnavailable, + }; +} + export class CustomWorldAgentOrchestrator { private readonly foundationDraftService: CustomWorldAgentFoundationDraftService; @@ -496,12 +217,29 @@ export class CustomWorldAgentOrchestrator { private readonly eightAnchorSingleTurnService: EightAnchorSingleTurnService; + private readonly suggestedActionService: CustomWorldAgentSuggestedActionService; + + private readonly qualityGateService: CustomWorldAgentQualityGateService; + + private readonly snapshotBuilder: CustomWorldAgentSnapshotBuilder; + + private readonly resultSyncService: CustomWorldAgentResultSyncService; + + private readonly publishingService: CustomWorldAgentPublishingService; + + private readonly actionRegistry: CustomWorldAgentActionRegistry; + + private readonly messageTurnService: CustomWorldAgentMessageTurnService; + constructor( private readonly sessionStore: CustomWorldAgentSessionStore, llmClient: UpstreamLlmClient | null = null, options: { singleTurnLlmClient?: UpstreamLlmClient | null; autoAssetService?: CustomWorldAgentAutoAssetService | null; + userRepository?: UserRepositoryPort | null; + resolveAuthorDisplayName?: ((userId: string) => Promise) | null; + rpgWorldProfileRepository?: RpgWorldProfileRepositoryPort | null; } = {}, ) { this.foundationDraftService = new CustomWorldAgentFoundationDraftService( @@ -513,11 +251,50 @@ export class CustomWorldAgentOrchestrator { ); this.changeSummaryService = new CustomWorldAgentChangeSummaryService(); this.assetBridgeService = new CustomWorldAgentAssetBridgeService(); - this.autoAssetService = - options.autoAssetService ?? null; + this.autoAssetService = options.autoAssetService ?? null; this.eightAnchorSingleTurnService = new EightAnchorSingleTurnService( (options.singleTurnLlmClient ?? llmClient) ?? undefined, ); + this.suggestedActionService = new CustomWorldAgentSuggestedActionService(); + this.qualityGateService = new CustomWorldAgentQualityGateService(); + this.snapshotBuilder = new CustomWorldAgentSnapshotBuilder( + this.draftCompiler, + this.suggestedActionService, + this.qualityGateService, + ); + this.resultSyncService = new CustomWorldAgentResultSyncService(); + this.publishingService = new CustomWorldAgentPublishingService( + options.rpgWorldProfileRepository ?? createUnavailablePublishingRepository(), + ); + const resolveAuthorDisplayName = + options.resolveAuthorDisplayName ?? + (options.userRepository + ? async (userId: string) => { + const user = await options.userRepository?.findById(userId); + return user?.displayName?.trim() || '玩家'; + } + : null); + this.messageTurnService = new CustomWorldAgentMessageTurnService( + this.sessionStore, + this.eightAnchorSingleTurnService, + this.suggestedActionService, + this.snapshotBuilder, + ); + this.actionRegistry = new CustomWorldAgentActionRegistry( + createCustomWorldAgentActionExecutorMap({ + sessionStore: this.sessionStore, + foundationDraftService: this.foundationDraftService, + draftCompiler: this.draftCompiler, + entityGenerationService: this.entityGenerationService, + changeSummaryService: this.changeSummaryService, + assetBridgeService: this.assetBridgeService, + autoAssetService: this.autoAssetService, + snapshotBuilder: this.snapshotBuilder, + resultSyncService: this.resultSyncService, + publishingService: this.publishingService, + resolveAuthorDisplayName, + }), + ); } async createSession( @@ -533,46 +310,50 @@ export class CustomWorldAgentOrchestrator { }) : {}; const creatorIntent = mergeCreatorIntentRecord(baseIntent, seedPatch); - const derivedState = buildDerivedState(creatorIntent, Boolean(seedText)); - const anchorContent = buildEightAnchorContentFromCreatorIntent(creatorIntent); + const initialState = this.messageTurnService.deriveInitialSessionState({ + seedText, + creatorIntent, + }); const progressPercent = seedText - ? estimateProgressPercentFromAnchorContent(anchorContent) + ? estimateProgressPercentFromAnchorContent(initialState.anchorContent) : 0; const fallbackWelcomeMessage = buildWelcomeMessage({ seedText, intent: creatorIntent, - pendingClarifications: derivedState.pendingClarifications, - isReady: derivedState.readiness.isReady, + pendingClarifications: initialState.pendingClarifications, + isReady: initialState.readiness.isReady, }); const record = await this.sessionStore.create(userId, { seedText, welcomeMessage: fallbackWelcomeMessage, currentTurn: 0, - anchorContent, + anchorContent: initialState.anchorContent, progressPercent, lastAssistantReply: fallbackWelcomeMessage, creatorIntent, - creatorIntentReadiness: derivedState.readiness, - anchorPack: buildAnchorPackFromEightAnchorContent( - anchorContent, - progressPercent, - ), - draftProfile: derivedState.draftProfile, - pendingClarifications: derivedState.pendingClarifications, + creatorIntentReadiness: initialState.readiness, + anchorPack: initialState.anchorPack, + draftProfile: initialState.draftProfile, + pendingClarifications: initialState.pendingClarifications, stage: progressPercent >= 100 ? 'foundation_review' : 'collecting_intent', - suggestedActions: derivedState.suggestedActions, + suggestedActions: initialState.suggestedActions, recommendedReplies: [], }); - return (await this.sessionStore.getSnapshot( + return (await this.getSessionSnapshot( userId, record.sessionId, )) as CustomWorldAgentSessionSnapshot; } async getSessionSnapshot(userId: string, sessionId: string) { - return this.sessionStore.getSnapshot(userId, sessionId); + const sessionRecord = await this.sessionStore.get(userId, sessionId); + if (!sessionRecord) { + return null; + } + + return this.buildSessionSnapshot(sessionRecord); } async submitMessage( @@ -679,191 +460,21 @@ export class CustomWorldAgentOrchestrator { throw notFound('custom world agent session not found'); } - if (payload.action === 'draft_foundation') { - if (session.progressPercent < 100) { - throw badRequest('draft_foundation requires progressPercent >= 100'); - } + const preparedExecution = this.actionRegistry.prepareExecution( + session, + payload, + ); + const operation = buildOperation(preparedExecution.operationType); + await this.sessionStore.createOperation(userId, sessionId, operation); + void preparedExecution.execute({ + userId, + sessionId, + operationId: operation.operationId, + }); - const operation = buildOperation('draft_foundation'); - await this.sessionStore.createOperation(userId, sessionId, operation); - void this.processDraftFoundationOperation({ - userId, - sessionId, - operationId: operation.operationId, - }); - - return { - operation, - }; - } - - if ( - payload.action === 'update_draft_card' || - payload.action === 'sync_result_profile' || - payload.action === 'generate_characters' || - payload.action === 'generate_landmarks' || - payload.action === 'generate_role_assets' || - payload.action === 'sync_role_assets' - ) { - if ( - session.stage !== 'object_refining' && - session.stage !== 'visual_refining' - ) { - throw badRequest( - `${payload.action} is only available during object_refining or visual_refining`, - ); - } - - const hasDraftFoundation = Boolean( - normalizeFoundationDraftProfile(session.draftProfile) && - session.draftCards.length > 0, - ); - if (!hasDraftFoundation) { - throw badRequest( - `${payload.action} requires an existing draft foundation`, - ); - } - } - - if (payload.action === 'update_draft_card') { - if (!payload.cardId.trim()) { - throw badRequest('update_draft_card requires cardId'); - } - if (!Array.isArray(payload.sections) || payload.sections.length === 0) { - throw badRequest('update_draft_card requires sections'); - } - - const operation = buildOperation('update_draft_card'); - await this.sessionStore.createOperation(userId, sessionId, operation); - void this.processUpdateDraftCardOperation({ - userId, - sessionId, - operationId: operation.operationId, - payload, - }); - - return { - operation, - }; - } - - if (payload.action === 'sync_result_profile') { - const normalizedProfile = normalizeCustomWorldProfile( - payload.profile, - '', - ); - if (!normalizedProfile) { - throw badRequest('sync_result_profile requires a valid profile'); - } - - const operation = buildOperation('sync_result_profile'); - await this.sessionStore.createOperation(userId, sessionId, operation); - void this.processSyncResultProfileOperation({ - userId, - sessionId, - operationId: operation.operationId, - payload: { - ...payload, - profile: normalizedProfile as unknown as Record, - }, - }); - - return { - operation, - }; - } - - if (payload.action === 'generate_characters') { - if (payload.count < 1 || payload.count > 3) { - throw badRequest('generate_characters count must be between 1 and 3'); - } - - const operation = buildOperation('generate_characters'); - await this.sessionStore.createOperation(userId, sessionId, operation); - void this.processGenerateCharactersOperation({ - userId, - sessionId, - operationId: operation.operationId, - payload, - }); - - return { - operation, - }; - } - - if (payload.action === 'generate_landmarks') { - if (payload.count < 1 || payload.count > 3) { - throw badRequest('generate_landmarks count must be between 1 and 3'); - } - - const operation = buildOperation('generate_landmarks'); - await this.sessionStore.createOperation(userId, sessionId, operation); - void this.processGenerateLandmarksOperation({ - userId, - sessionId, - operationId: operation.operationId, - payload, - }); - - return { - operation, - }; - } - - if (payload.action === 'generate_role_assets') { - if (!Array.isArray(payload.roleIds) || payload.roleIds.length !== 1) { - throw badRequest( - 'generate_role_assets currently requires exactly one roleId', - ); - } - - const operation = buildOperation('generate_role_assets'); - await this.sessionStore.createOperation(userId, sessionId, operation); - void this.processGenerateRoleAssetsOperation({ - userId, - sessionId, - operationId: operation.operationId, - payload, - }); - - return { - operation, - }; - } - - if (payload.action === 'sync_role_assets') { - if (!payload.roleId.trim()) { - throw badRequest('sync_role_assets requires roleId'); - } - if ( - !payload.portraitPath.trim() || - !payload.generatedVisualAssetId.trim() - ) { - throw badRequest( - 'sync_role_assets requires portraitPath and generatedVisualAssetId', - ); - } - - const operation = buildOperation('sync_role_assets'); - await this.sessionStore.createOperation(userId, sessionId, operation); - void this.processSyncRoleAssetsOperation({ - userId, - sessionId, - operationId: operation.operationId, - payload, - }); - - return { - operation, - }; - } - - if (payload.action === 'publish_world') { - throw badRequest('publish_world is not available in phase5'); - } - - throw badRequest(`${payload.action} is not available in phase5`); + return { + operation, + }; } async getOperation(userId: string, sessionId: string, operationId: string) { @@ -887,898 +498,103 @@ export class CustomWorldAgentOrchestrator { relatedOperationId?: string | null; onReplyUpdate?: (text: string) => void; }) { - const latestSession = (await this.sessionStore.get( - params.userId, - params.sessionId, - )) as CustomWorldAgentSessionRecord | null; - if (!latestSession) { - throw new Error('custom world agent session not found'); - } - - const shouldPreserveDraftStage = - (latestSession.stage === 'object_refining' || - latestSession.stage === 'visual_refining') && - latestSession.draftCards.length > 0; - - const assistantTurn = await this.eightAnchorSingleTurnService.streamTurn( - { - currentTurn: latestSession.currentTurn + 1, - progressPercent: latestSession.progressPercent, - quickFillRequested: params.quickFillRequested, - currentAnchorContent: latestSession.anchorContent, - chatHistory: latestSession.messages - .filter( - (message): message is CustomWorldAgentMessage => - (message.role === 'user' || message.role === 'assistant') && - Boolean(message.text.trim()), - ) - .map((message) => ({ - role: message.role, - content: message.text, - })), - }, - { - onReplyUpdate: params.onReplyUpdate, - }, - ); - const nextCreatorIntent = buildCreatorIntentFromEightAnchorContent( - assistantTurn.nextAnchorContent, - ); - const progressPercent = Math.max( - 0, - Math.min(100, Math.round(assistantTurn.progressPercent)), - ); - const creatorIntentReadiness = - progressPercent >= 100 - ? { - isReady: true, - completedKeys: [ - 'world_hook', - 'player_premise', - 'theme_and_tone', - 'core_conflict', - 'relationship_seed', - 'iconic_element', - ], - missingKeys: [], - } - : evaluateCreatorIntentReadiness(nextCreatorIntent); - const derivedState = buildDerivedState(nextCreatorIntent, true); - const preservedStage = - latestSession.stage === 'visual_refining' - ? ('visual_refining' as const) - : ('object_refining' as const); - const shouldStayInDraftStage = - shouldPreserveDraftStage && progressPercent >= 100; - const nextStage = shouldStayInDraftStage - ? preservedStage - : derivedState.stage; - const assistantMessage = { - id: `message-${crypto.randomBytes(8).toString('hex')}`, - role: 'assistant', - kind: 'chat', - text: assistantTurn.replyText, - createdAt: new Date().toISOString(), - relatedOperationId: params.relatedOperationId ?? null, - } satisfies CustomWorldAgentMessage; - - await this.sessionStore.replaceDerivedState(params.userId, params.sessionId, { - currentTurn: latestSession.currentTurn + 1, - anchorContent: assistantTurn.nextAnchorContent, - progressPercent, - lastAssistantReply: assistantTurn.replyText, - stage: nextStage, - focusCardId: shouldStayInDraftStage ? latestSession.focusCardId : null, - creatorIntent: nextCreatorIntent, - creatorIntentReadiness, - anchorPack: buildAnchorPackFromEightAnchorContent( - assistantTurn.nextAnchorContent, - progressPercent, - ), - draftProfile: shouldStayInDraftStage - ? latestSession.draftProfile - : progressPercent >= 100 - ? { - title: buildDraftTitleFromIntent(nextCreatorIntent), - summary: buildDraftSummaryFromIntent(nextCreatorIntent), - } - : derivedState.draftProfile, - draftCards: shouldStayInDraftStage ? latestSession.draftCards : [], - assetCoverage: shouldStayInDraftStage - ? latestSession.assetCoverage - : rebuildRoleAssetCoverage( - progressPercent >= 100 - ? { - title: buildDraftTitleFromIntent(nextCreatorIntent), - summary: buildDraftSummaryFromIntent(nextCreatorIntent), - } - : derivedState.draftProfile, - ), - pendingClarifications: - progressPercent >= 100 ? [] : derivedState.pendingClarifications, - suggestedActions: shouldStayInDraftStage - ? buildSuggestedActions({ - stage: preservedStage, - isReady: true, - draftProfile: latestSession.draftProfile, - draftCards: latestSession.draftCards, - }) - : progressPercent >= 100 - ? [ - { - id: 'draft_foundation', - type: 'draft_foundation', - label: '生成游戏设定草稿', - }, - ] - : [], - recommendedReplies: [], - }); - await this.sessionStore.appendMessage( - params.userId, - params.sessionId, - assistantMessage, - ); - - return (await this.sessionStore.getSnapshot( - params.userId, - params.sessionId, - )) as CustomWorldAgentSessionSnapshot; + await this.messageTurnService.applyMessageTurn(params); + return this.getSessionSnapshot(params.userId, params.sessionId); } - private async processDraftFoundationOperation(params: { - userId: string; - sessionId: string; - operationId: string; - }) { - const { userId, sessionId, operationId } = params; + /** + * 统一 session snapshot 的读模型装配口径,避免普通拉取、SSE 流和内部调用返回不同字段集合。 + */ + private buildSessionSnapshot( + sessionRecord: CustomWorldAgentSessionRecord, + ): CustomWorldAgentSessionSnapshot { + const snapshot = { + sessionId: sessionRecord.sessionId, + currentTurn: sessionRecord.currentTurn, + anchorContent: sessionRecord.anchorContent, + progressPercent: sessionRecord.progressPercent, + lastAssistantReply: sessionRecord.lastAssistantReply, + stage: sessionRecord.stage, + focusCardId: sessionRecord.focusCardId, + creatorIntent: sessionRecord.creatorIntent, + creatorIntentReadiness: sessionRecord.creatorIntentReadiness, + anchorPack: sessionRecord.anchorPack, + lockState: sessionRecord.lockState, + draftProfile: sessionRecord.draftProfile, + messages: sessionRecord.messages, + draftCards: sessionRecord.draftCards, + pendingClarifications: sessionRecord.pendingClarifications, + suggestedActions: sessionRecord.suggestedActions, + recommendedReplies: sessionRecord.recommendedReplies, + qualityFindings: sessionRecord.qualityFindings, + assetCoverage: sessionRecord.assetCoverage, + checkpoints: sessionRecord.checkpoints.map((checkpoint) => ({ + checkpointId: checkpoint.checkpointId, + createdAt: checkpoint.createdAt, + label: checkpoint.label, + })), + supportedActions: this.actionRegistry.buildSupportedActions(sessionRecord), + resultPreview: this.buildResultPreview(sessionRecord), + updatedAt: sessionRecord.updatedAt, + } satisfies CustomWorldAgentSessionSnapshot; - try { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'running', - phaseLabel: '整理世界骨架', - phaseDetail: '正在校验已确认锚点,并准备第一版世界框架生成链路。', - progress: 12, - }); - - await sleep(30); - - const latestSession = (await this.sessionStore.get( - userId, - sessionId, - )) as CustomWorldAgentSessionRecord | null; - if (!latestSession) { - throw new Error('custom world agent session not found'); - } - - if (latestSession.progressPercent < 100) { - throw new Error('session progressPercent is below 100'); - } - - const creatorIntent = buildCreatorIntentFromEightAnchorContent( - latestSession.anchorContent, - ); - const anchorPack = buildAnchorPackFromEightAnchorContent( - latestSession.anchorContent, - latestSession.progressPercent, - ); - - const draftProfile = await this.foundationDraftService.generate({ - creatorIntent, - anchorPack, - anchorContent: latestSession.anchorContent, - onProgress: async (progress) => { - await this.sessionStore.updateOperation( - userId, - sessionId, - operationId, - { - status: 'running', - phaseLabel: progress.phaseLabel, - phaseDetail: progress.phaseDetail, - progress: progress.progress, - }, - ); - }, - }); - - const draftWithAssets = this.autoAssetService - ? await this.autoAssetService.populateDraftAssets({ - draftProfile, - onProgress: async (progress) => { - await this.sessionStore.updateOperation( - userId, - sessionId, - operationId, - { - status: 'running', - phaseLabel: progress.phaseLabel, - phaseDetail: progress.phaseDetail, - progress: progress.progress, - }, - ); - }, - }) - : { - draftProfile, - assetCoverage: rebuildRoleAssetCoverage(draftProfile), - warnings: [], - }; - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - phaseLabel: '编译草稿卡', - phaseDetail: '正在把世界底稿整理成可浏览的卡片摘要和详情结构。', - progress: 98, - }); - - const draftCards = this.draftCompiler.compileDraftCards( - draftWithAssets.draftProfile, - ); - const assetCoverage = draftWithAssets.assetCoverage; - const nextStage = 'object_refining' as const; - const nextSuggestedActions = buildSuggestedActions({ - stage: nextStage, - isReady: true, - draftProfile: draftWithAssets.draftProfile, - draftCards, - }); - - await this.sessionStore.replaceDerivedState(userId, sessionId, { - stage: nextStage, - creatorIntent, - anchorPack, - draftProfile: - draftWithAssets.draftProfile as unknown as Record, - draftCards, - assetCoverage, - pendingClarifications: [], - suggestedActions: nextSuggestedActions, - recommendedReplies: [], - }); - await this.sessionStore.appendCheckpoint(userId, sessionId, { - label: '世界底稿 V1', - }); - await this.sessionStore.appendMessage( - userId, - sessionId, - buildFoundationDraftAssistantMessage({ - relatedOperationId: operationId, - draftProfile: draftWithAssets.draftProfile, - warnings: draftWithAssets.warnings, - }), - ); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'completed', - phaseLabel: '世界底稿已生成', - phaseDetail: - draftWithAssets.warnings.length > 0 - ? `第一版世界底稿和 ${draftCards.length} 张草稿卡已经整理完成,另有 ${draftWithAssets.warnings.length} 项资产补齐待后续处理。` - : `第一版世界底稿和 ${draftCards.length} 张草稿卡已经整理完成。`, - progress: 100, - error: null, - }); - } catch (error) { - const currentOperation = await this.sessionStore.getOperation( - userId, - sessionId, - operationId, - ); - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'failed', - phaseLabel: - currentOperation?.phaseLabel?.trim() || '底稿生成失败', - phaseDetail: - currentOperation?.phaseDetail?.trim() || - '这一轮没有成功把设定编成世界底稿。', - progress: 100, - error: - error instanceof Error ? error.message : 'draft foundation failed', - }); - } + return snapshot; } - private async processUpdateDraftCardOperation(params: { - userId: string; - sessionId: string; - operationId: string; - payload: Extract< - CustomWorldAgentActionRequest, - { action: 'update_draft_card' } - >; - }) { - const { userId, sessionId, operationId, payload } = params; + /** + * 当前仍输出 legacy-compatible preview envelope,但正式把它接入 session snapshot 主链, + * 为后续结果页切换到服务端 preview 数据源提供稳定入口。 + */ + private buildResultPreview( + sessionRecord: CustomWorldAgentSessionRecord, + ): CustomWorldAgentSessionSnapshot['resultPreview'] { + const draftProfile = + sessionRecord.draftProfile && + typeof sessionRecord.draftProfile === 'object' && + !Array.isArray(sessionRecord.draftProfile) + ? (sessionRecord.draftProfile as Record) + : null; - try { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'running', - phaseLabel: '写回草稿设定', - phaseDetail: '正在把这次编辑内容写回当前世界底稿。', - progress: 34, - }); - - const latestSession = (await this.sessionStore.get( - userId, - sessionId, - )) as CustomWorldAgentSessionRecord | null; - if (!latestSession) { - throw new Error('custom world agent session not found'); - } - - const nextDraftProfile = updateDraftCardSections({ - draftProfile: (latestSession.draftProfile ?? {}) as Record< - string, - unknown - >, - cardId: payload.cardId, - sections: payload.sections, - }); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - phaseLabel: '重编译草稿卡', - phaseDetail: '正在同步更新草稿摘要和详情内容。', - progress: 72, - }); - - const nextDraftCards = - this.draftCompiler.compileDraftCards(nextDraftProfile); - const assetCoverage = rebuildRoleAssetCoverage(nextDraftProfile); - const nextStage = - latestSession.stage === 'visual_refining' - ? ('visual_refining' as const) - : ('object_refining' as const); - const nextSuggestedActions = buildSuggestedActions({ - stage: nextStage, - isReady: true, - draftProfile: nextDraftProfile, - draftCards: nextDraftCards, - }); - const updatedDetail = this.draftCompiler.getDraftCardDetail( - nextDraftProfile, - payload.cardId, - ); - const changedSectionIds = new Set( - payload.sections - .map((section) => section.sectionId.trim()) - .filter(Boolean), - ); - - await this.sessionStore.replaceDerivedState(userId, sessionId, { - stage: nextStage, - draftProfile: nextDraftProfile, - draftCards: nextDraftCards, - assetCoverage, - focusCardId: payload.cardId, - suggestedActions: nextSuggestedActions, - recommendedReplies: [], - }); - await this.sessionStore.appendCheckpoint(userId, sessionId, { - label: `编辑 ${updatedDetail?.title || '草稿卡'}`, - }); - await this.sessionStore.appendMessage( - userId, - sessionId, - buildActionResultMessage({ - relatedOperationId: operationId, - text: this.changeSummaryService.buildSummary({ - action: 'update_draft_card', - cardId: payload.cardId, - changedLabels: - updatedDetail?.sections - .filter((section) => changedSectionIds.has(section.id)) - .map((section) => section.label) ?? [], - draftProfile: nextDraftProfile, - }), - }), - ); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'completed', - phaseLabel: '草稿设定已保存', - phaseDetail: `「${updatedDetail?.title || '当前卡片'}」的设定已经同步更新。`, - progress: 100, - error: null, - }); - } catch (error) { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'failed', - phaseLabel: '保存失败', - phaseDetail: '这次草稿编辑没有成功写回到底稿。', - progress: 100, - error: - error instanceof Error ? error.message : 'update draft card failed', - }); + if (!draftProfile) { + return null; } - } - private async processSyncResultProfileOperation(params: { - userId: string; - sessionId: string; - operationId: string; - payload: Extract< - CustomWorldAgentActionRequest, - { action: 'sync_result_profile' } - >; - }) { - const { userId, sessionId, operationId, payload } = params; - - try { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'running', - phaseLabel: '同步结果页快照', - phaseDetail: '正在把结果页里的最新世界结构写回当前草稿。', - progress: 36, - }); - - const latestSession = (await this.sessionStore.get( - userId, - sessionId, - )) as CustomWorldAgentSessionRecord | null; - if (!latestSession) { - throw new Error('custom world agent session not found'); - } - - const resultProfile = payload.profile as unknown as CustomWorldProfile; - const nextDraftProfile = syncResultProfileIntoDraftProfile({ - currentDraftProfile: latestSession.draftProfile, - resultProfile, - }); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - phaseLabel: '重编译草稿摘要', - phaseDetail: '正在刷新草稿卡摘要、资产覆盖和结果页恢复快照。', - progress: 72, - }); - - const nextDraftCards = this.draftCompiler.compileDraftCards(nextDraftProfile); - const assetCoverage = rebuildRoleAssetCoverage(nextDraftProfile); - const nextStage = - latestSession.stage === 'visual_refining' - ? ('visual_refining' as const) - : ('object_refining' as const); - const nextSuggestedActions = buildSuggestedActions({ - stage: nextStage, - isReady: true, - draftProfile: nextDraftProfile, - draftCards: nextDraftCards, - }); - - await this.sessionStore.replaceDerivedState(userId, sessionId, { - stage: nextStage, - draftProfile: nextDraftProfile, - draftCards: nextDraftCards, - assetCoverage, - suggestedActions: nextSuggestedActions, - recommendedReplies: [], - }); - await this.sessionStore.appendCheckpoint(userId, sessionId, { - label: '同步结果页编辑', - }); - await this.sessionStore.appendMessage( - userId, - sessionId, - buildActionResultMessage({ - relatedOperationId: operationId, - text: '结果页里的最新世界结构已经同步回当前草稿。', - }), - ); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'completed', - phaseLabel: '结果页快照已同步', - phaseDetail: '当前结果页编辑内容已经并回 Agent 草稿主链。', - progress: 100, - error: null, - }); - } catch (error) { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'failed', - phaseLabel: '结果页同步失败', - phaseDetail: '这一轮结果页编辑没有成功写回当前草稿。', - progress: 100, - error: - error instanceof Error ? error.message : 'sync result profile failed', - }); + if (!normalizeFoundationDraftProfile(draftProfile)) { + return null; } - } - - private async processGenerateCharactersOperation(params: { - userId: string; - sessionId: string; - operationId: string; - payload: Extract< - CustomWorldAgentActionRequest, - { action: 'generate_characters' } - >; - }) { - const { userId, sessionId, operationId, payload } = params; try { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'running', - phaseLabel: '生成新角色', - phaseDetail: '正在围绕当前世界底稿补出新角色。', - progress: 32, + const publishGate = this.publishingService.summarizePublishGate({ + sessionId: sessionRecord.sessionId, + stage: sessionRecord.stage, + draftProfile, + qualityFindings: sessionRecord.qualityFindings, + }); + const previewProfile = buildRpgCreationPreviewProfileFromDraftProfile({ + sessionId: sessionRecord.sessionId, + draftProfile, + profileId: publishGate.profileId, }); - const latestSession = (await this.sessionStore.get( - userId, - sessionId, - )) as CustomWorldAgentSessionRecord | null; - if (!latestSession) { - throw new Error('custom world agent session not found'); - } - - const generationResult = - await this.entityGenerationService.generateAdditionalCharacters({ - creatorIntent: latestSession.creatorIntent, - anchorPack: latestSession.anchorPack, - draftProfile: (latestSession.draftProfile ?? {}) as Record< - string, - unknown - >, - count: payload.count, - promptText: payload.promptText, - anchorCardIds: - payload.anchorCardIds && payload.anchorCardIds.length > 0 - ? payload.anchorCardIds - : latestSession.focusCardId - ? [latestSession.focusCardId] - : [getWorldFoundationCardId()], - }); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - phaseLabel: '插入新角色卡', - phaseDetail: '正在把新角色插回草稿并刷新卡片列表。', - progress: 74, - }); - - const nextDraftCards = this.draftCompiler.compileDraftCards( - generationResult.draftProfile, - ); - const assetCoverage = rebuildRoleAssetCoverage( - generationResult.draftProfile, - ); - const nextStage = - latestSession.stage === 'visual_refining' - ? ('visual_refining' as const) - : ('object_refining' as const); - const nextSuggestedActions = buildSuggestedActions({ - stage: nextStage, - isReady: true, - draftProfile: generationResult.draftProfile, - draftCards: nextDraftCards, - }); - const focusCardId = generationResult.generatedCharacters[0]?.id ?? null; - - await this.sessionStore.replaceDerivedState(userId, sessionId, { - stage: nextStage, - draftProfile: generationResult.draftProfile, - draftCards: nextDraftCards, - assetCoverage, - focusCardId, - suggestedActions: nextSuggestedActions, - recommendedReplies: [], - }); - await this.sessionStore.appendCheckpoint(userId, sessionId, { - label: `新增角色 ${generationResult.generatedCharacters.length} 个`, - }); - await this.sessionStore.appendMessage( - userId, - sessionId, - buildActionResultMessage({ - relatedOperationId: operationId, - text: this.changeSummaryService.buildSummary({ - action: 'generate_characters', - names: generationResult.generatedCharacters.map( - (entry) => entry.name, - ), - draftProfile: generationResult.draftProfile, - }), - }), - ); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'completed', - phaseLabel: '新角色已加入草稿', - phaseDetail: `已补出 ${generationResult.generatedCharacters.length} 个新角色。`, - progress: 100, - error: null, - }); - } catch (error) { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'failed', - phaseLabel: '角色生成失败', - phaseDetail: '这一轮没有成功补出新角色。', - progress: 100, - error: - error instanceof Error ? error.message : 'generate characters failed', - }); - } - } - - private async processGenerateLandmarksOperation(params: { - userId: string; - sessionId: string; - operationId: string; - payload: Extract< - CustomWorldAgentActionRequest, - { action: 'generate_landmarks' } - >; - }) { - const { userId, sessionId, operationId, payload } = params; - - try { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'running', - phaseLabel: '生成新地点', - phaseDetail: '正在围绕当前世界底稿补出新地点。', - progress: 32, - }); - - const latestSession = (await this.sessionStore.get( - userId, - sessionId, - )) as CustomWorldAgentSessionRecord | null; - if (!latestSession) { - throw new Error('custom world agent session not found'); - } - - const generationResult = - await this.entityGenerationService.generateAdditionalLandmarks({ - creatorIntent: latestSession.creatorIntent, - anchorPack: latestSession.anchorPack, - draftProfile: (latestSession.draftProfile ?? {}) as Record< - string, - unknown - >, - count: payload.count, - promptText: payload.promptText, - anchorCardIds: - payload.anchorCardIds && payload.anchorCardIds.length > 0 - ? payload.anchorCardIds - : latestSession.focusCardId - ? [latestSession.focusCardId] - : [getWorldFoundationCardId()], - }); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - phaseLabel: '插入新地点卡', - phaseDetail: '正在把新地点插回草稿并刷新卡片列表。', - progress: 74, - }); - - const nextDraftCards = this.draftCompiler.compileDraftCards( - generationResult.draftProfile, - ); - const assetCoverage = rebuildRoleAssetCoverage( - generationResult.draftProfile, - ); - const nextStage = - latestSession.stage === 'visual_refining' - ? ('visual_refining' as const) - : ('object_refining' as const); - const nextSuggestedActions = buildSuggestedActions({ - stage: nextStage, - isReady: true, - draftProfile: generationResult.draftProfile, - draftCards: nextDraftCards, - }); - const focusCardId = generationResult.generatedLandmarks[0]?.id ?? null; - - await this.sessionStore.replaceDerivedState(userId, sessionId, { - stage: nextStage, - draftProfile: generationResult.draftProfile, - draftCards: nextDraftCards, - assetCoverage, - focusCardId, - suggestedActions: nextSuggestedActions, - recommendedReplies: [], - }); - await this.sessionStore.appendCheckpoint(userId, sessionId, { - label: `新增地点 ${generationResult.generatedLandmarks.length} 个`, - }); - await this.sessionStore.appendMessage( - userId, - sessionId, - buildActionResultMessage({ - relatedOperationId: operationId, - text: this.changeSummaryService.buildSummary({ - action: 'generate_landmarks', - names: generationResult.generatedLandmarks.map( - (entry) => entry.name, - ), - draftProfile: generationResult.draftProfile, - }), - }), - ); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'completed', - phaseLabel: '新地点已加入草稿', - phaseDetail: `已补出 ${generationResult.generatedLandmarks.length} 个新地点。`, - progress: 100, - error: null, - }); - } catch (error) { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'failed', - phaseLabel: '地点生成失败', - phaseDetail: '这一轮没有成功补出新地点。', - progress: 100, - error: - error instanceof Error ? error.message : 'generate landmarks failed', - }); - } - } - - private async processGenerateRoleAssetsOperation(params: { - userId: string; - sessionId: string; - operationId: string; - payload: Extract< - CustomWorldAgentActionRequest, - { action: 'generate_role_assets' } - >; - }) { - const { userId, sessionId, operationId, payload } = params; - - try { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'running', - phaseLabel: '准备角色资产工坊', - phaseDetail: '正在校验角色并整理工坊上下文。', - progress: 40, - }); - - const latestSession = (await this.sessionStore.get( - userId, - sessionId, - )) as CustomWorldAgentSessionRecord | null; - if (!latestSession) { - throw new Error('custom world agent session not found'); - } - - const roleId = payload.roleIds[0]!; - const studioContext = this.assetBridgeService.buildRoleAssetStudioContext( - latestSession.draftProfile, - roleId, - ); - const nextStage = 'visual_refining' as const; - const nextSuggestedActions = buildSuggestedActions({ - stage: nextStage, - isReady: true, - draftProfile: latestSession.draftProfile, - draftCards: latestSession.draftCards, - }); - - await this.sessionStore.replaceDerivedState(userId, sessionId, { - stage: nextStage, - focusCardId: roleId, - suggestedActions: nextSuggestedActions, - recommendedReplies: [], - }); - await this.sessionStore.appendMessage( - userId, - sessionId, - buildActionResultMessage({ - relatedOperationId: operationId, - text: `已为「${studioContext.roleName}」准备好角色资产工坊,先生成主图候选,再补核心动作。`, - }), - ); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'completed', - phaseLabel: '角色资产工坊已就绪', - phaseDetail: `「${studioContext.roleName}」现在可以开始生成主图和动作。`, - progress: 100, - error: null, - }); - } catch (error) { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'failed', - phaseLabel: '角色资产工坊准备失败', - phaseDetail: '这一轮没有成功进入角色资产工坊。', - progress: 100, - error: - error instanceof Error - ? error.message - : 'generate role assets failed', - }); - } - } - - private async processSyncRoleAssetsOperation(params: { - userId: string; - sessionId: string; - operationId: string; - payload: Extract< - CustomWorldAgentActionRequest, - { action: 'sync_role_assets' } - >; - }) { - const { userId, sessionId, operationId, payload } = params; - - try { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'running', - phaseLabel: '同步角色资产', - phaseDetail: '正在把主图与动作结果写回当前世界草稿。', - progress: 36, - }); - - const latestSession = (await this.sessionStore.get( - userId, - sessionId, - )) as CustomWorldAgentSessionRecord | null; - if (!latestSession) { - throw new Error('custom world agent session not found'); - } - - const syncResult = this.assetBridgeService.applyRoleAssetPublishResult( - latestSession.draftProfile, - payload, - ); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - phaseLabel: '刷新角色卡摘要', - phaseDetail: '正在同步更新角色卡状态与资产覆盖。', - progress: 72, - }); - - const nextDraftCards = this.draftCompiler.compileDraftCards( - syncResult.draftProfile, - ); - const assetCoverage = rebuildRoleAssetCoverage(syncResult.draftProfile); - const nextSuggestedActions = buildSuggestedActions({ - stage: 'visual_refining', - isReady: true, - draftProfile: syncResult.draftProfile, - draftCards: nextDraftCards, - }); - - await this.sessionStore.replaceDerivedState(userId, sessionId, { - stage: 'visual_refining', - draftProfile: syncResult.draftProfile, - draftCards: nextDraftCards, - assetCoverage, - focusCardId: payload.roleId, - suggestedActions: nextSuggestedActions, - recommendedReplies: [], - }); - await this.sessionStore.appendCheckpoint(userId, sessionId, { - label: `同步角色资产 ${syncResult.updatedAssetSummary.roleName}`, - }); - await this.sessionStore.appendMessage( - userId, - sessionId, - buildActionResultMessage({ - relatedOperationId: operationId, - text: buildRoleAssetSyncResultText({ - roleName: syncResult.updatedAssetSummary.roleName, - assetStatusLabel: resolveRoleAssetStatusLabel( - syncResult.updatedAssetSummary.status, - ), - }), - }), - ); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'completed', - phaseLabel: '角色资产已同步', - phaseDetail: `「${syncResult.updatedAssetSummary.roleName}」的资产状态已更新为${resolveRoleAssetStatusLabel(syncResult.updatedAssetSummary.status)}。`, - progress: 100, - error: null, - }); - } catch (error) { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'failed', - phaseLabel: '角色资产同步失败', - phaseDetail: '这一轮没有成功把角色资产写回草稿。', - progress: 100, - error: - error instanceof Error ? error.message : 'sync role assets failed', - }); + return { + ...buildRpgWorldPreviewEnvelope( + previewProfile, + String(previewProfile.settingText ?? ''), + ), + generatedAt: sessionRecord.updatedAt, + qualityFindings: sessionRecord.qualityFindings.map((finding) => ({ + id: finding.id, + severity: finding.severity, + code: finding.code, + targetId: finding.targetId ?? null, + message: finding.message, + })), + blockers: publishGate.blockers, + publishReady: publishGate.publishReady, + canEnterWorld: publishGate.canEnterWorld, + }; + } catch { + return null; } } diff --git a/server-node/src/services/customWorldAgentPhase2.test.ts b/server-node/src/services/customWorldAgentPhase2.test.ts index a083ceb4..46532ed1 100644 --- a/server-node/src/services/customWorldAgentPhase2.test.ts +++ b/server-node/src/services/customWorldAgentPhase2.test.ts @@ -1,8 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; -import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; import { buildPendingClarifications, evaluateCreatorIntentReadiness, @@ -12,90 +10,11 @@ import { mergeCreatorIntentRecord, } from './customWorldAgentIntentExtractionService.js'; 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'; -function createRuntimeRepositoryStub(): RuntimeRepositoryPort { - const sessionsByUser = new Map< - string, - Map - >(); - const profilesByUser = new Map[]>(); - - const getSessionBucket = (userId: string) => { - const existing = sessionsByUser.get(userId); - if (existing) { - return existing; - } - - const nextBucket = new Map(); - sessionsByUser.set(userId, nextBucket); - return nextBucket; - }; - - return { - async getSnapshot(_userId) { - return null; - }, - async putSnapshot(_userId, _payload) { - throw new Error('not implemented'); - }, - async deleteSnapshot(_userId) { - return undefined; - }, - async getSettings() { - return { - musicVolume: 0.42, - platformTheme: 'light', - }; - }, - async putSettings(_userId, settings) { - return settings; - }, - async listCustomWorldProfiles(userId) { - return [...(profilesByUser.get(userId) ?? [])]; - }, - async upsertCustomWorldProfile(userId, profileId, profile) { - const current = [...(profilesByUser.get(userId) ?? [])].filter( - (item) => String(item.id ?? '') !== profileId, - ); - current.unshift({ - ...profile, - id: profileId, - }); - profilesByUser.set(userId, current); - return current; - }, - async deleteCustomWorldProfile(userId, profileId) { - const current = [...(profilesByUser.get(userId) ?? [])].filter( - (item) => String(item.id ?? '') !== profileId, - ); - profilesByUser.set(userId, current); - return current; - }, - async listProfileSaveArchives() { - return []; - }, - async resumeProfileSaveArchive() { - return null; - }, - async listCustomWorldSessions(userId) { - return [...getSessionBucket(userId).values()]; - }, - async getCustomWorldSession(userId, sessionId) { - return getSessionBucket(userId).get(sessionId) ?? null; - }, - async upsertCustomWorldSession(userId, sessionId, session) { - getSessionBucket(userId).set( - sessionId, - JSON.parse(JSON.stringify(session)), - ); - return JSON.parse(JSON.stringify(session)); - }, - }; -} - async function waitForOperation( orchestrator: CustomWorldAgentOrchestrator, userId: string, @@ -184,8 +103,10 @@ test('phase2 clarification service only keeps the top highest leverage gap', () }); test('phase2 orchestrator advances session to foundation_review when minimal anchors are complete', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); @@ -265,8 +186,11 @@ test('phase2 orchestrator advances session to foundation_review when minimal anc }); test('phase2 work summaries compile draft title and summary from creator intent', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository, rpgWorldProfileRepository } = + createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); @@ -294,7 +218,7 @@ test('phase2 work summaries compile draft title and summary from creator intent' ); const items = await listCustomWorldWorkSummaries(userId, { - runtimeRepository, + rpgWorldProfiles: rpgWorldProfileRepository, customWorldAgentSessions: sessionStore, }); const draft = items.find( diff --git a/server-node/src/services/customWorldAgentPhase3.test.ts b/server-node/src/services/customWorldAgentPhase3.test.ts index bb6136dc..72384507 100644 --- a/server-node/src/services/customWorldAgentPhase3.test.ts +++ b/server-node/src/services/customWorldAgentPhase3.test.ts @@ -4,96 +4,15 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; -import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; import type { AppConfig } from '../config.js'; -import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js'; import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; 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'; -function createRuntimeRepositoryStub(): RuntimeRepositoryPort { - const sessionsByUser = new Map< - string, - Map - >(); - const profilesByUser = new Map[]>(); - - const getSessionBucket = (userId: string) => { - const existing = sessionsByUser.get(userId); - if (existing) { - return existing; - } - - const nextBucket = new Map(); - sessionsByUser.set(userId, nextBucket); - return nextBucket; - }; - - return { - async getSnapshot(_userId) { - return null; - }, - async putSnapshot(_userId, _payload) { - throw new Error('not implemented'); - }, - async deleteSnapshot(_userId) { - return undefined; - }, - async getSettings() { - return { - musicVolume: 0.42, - platformTheme: 'light', - }; - }, - async putSettings(_userId, settings) { - return settings; - }, - async listCustomWorldProfiles(userId) { - return [...(profilesByUser.get(userId) ?? [])]; - }, - async upsertCustomWorldProfile(userId, profileId, profile) { - const current = [...(profilesByUser.get(userId) ?? [])].filter( - (item) => String(item.id ?? '') !== profileId, - ); - current.unshift({ - ...profile, - id: profileId, - }); - profilesByUser.set(userId, current); - return current; - }, - async deleteCustomWorldProfile(userId, profileId) { - const current = [...(profilesByUser.get(userId) ?? [])].filter( - (item) => String(item.id ?? '') !== profileId, - ); - profilesByUser.set(userId, current); - return current; - }, - async listProfileSaveArchives() { - return []; - }, - async resumeProfileSaveArchive() { - return null; - }, - async listCustomWorldSessions(userId) { - return [...getSessionBucket(userId).values()]; - }, - async getCustomWorldSession(userId, sessionId) { - return getSessionBucket(userId).get(sessionId) ?? null; - }, - async upsertCustomWorldSession(userId, sessionId, session) { - getSessionBucket(userId).set( - sessionId, - JSON.parse(JSON.stringify(session)), - ); - return JSON.parse(JSON.stringify(session)); - }, - }; -} - function createAutoAssetTestConfig(testName: string): AppConfig { const projectRoot = fs.mkdtempSync( path.join(os.tmpdir(), `genarrative-agent-phase3-${testName}-`), @@ -259,13 +178,28 @@ async function createReadySession( assert.equal(readySession?.stage, 'foundation_review'); assert.equal(readySession?.creatorIntentReadiness.isReady, true); + assert.equal(readySession?.resultPreview, null); + assert.equal( + readySession?.supportedActions?.find( + (entry) => entry.action === 'draft_foundation', + )?.enabled, + true, + ); + assert.equal( + readySession?.supportedActions?.find( + (entry) => entry.action === 'sync_result_profile', + )?.enabled, + false, + ); return readySession!; } test('phase3 ready session can execute draft_foundation and expose card detail', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), autoAssetService: createFallbackAutoAssetService('draft'), @@ -301,6 +235,21 @@ test('phase3 ready session can execute draft_foundation and expose card detail', assert.equal(operation?.status, 'completed'); assert.equal(snapshot?.stage, 'object_refining'); assert.ok(snapshot?.draftCards.length); + assert.equal(snapshot?.resultPreview?.source, 'session_preview'); + assert.equal( + snapshot?.resultPreview?.preview.name, + typeof (snapshot?.draftProfile as Record)?.name === 'string' + ? ((snapshot?.draftProfile as Record).name as string) + : '未命名世界底稿', + ); + assert.ok(Array.isArray(snapshot?.resultPreview?.blockers)); + assert.ok((snapshot?.resultPreview?.blockers?.length ?? 0) >= 0); + assert.equal(snapshot?.resultPreview?.publishReady, false); + assert.equal(snapshot?.resultPreview?.canEnterWorld, false); + assert.equal( + snapshot?.resultPreview?.qualityFindings?.length, + snapshot?.qualityFindings.length, + ); assert.ok(snapshot?.draftCards.some((card) => card.kind === 'world')); assert.ok(snapshot?.draftCards.some((card) => card.kind === 'faction')); assert.ok(snapshot?.draftCards.some((card) => card.kind === 'character')); @@ -335,6 +284,24 @@ test('phase3 ready session can execute draft_foundation and expose card detail', message.text.includes('第一版世界底稿整理出来了'), ), ); + assert.equal( + snapshot?.supportedActions?.find( + (entry) => entry.action === 'update_draft_card', + )?.enabled, + true, + ); + assert.equal( + snapshot?.supportedActions?.find( + (entry) => entry.action === 'generate_role_assets', + )?.enabled, + true, + ); + assert.equal( + snapshot?.supportedActions?.find( + (entry) => entry.action === 'publish_world', + )?.enabled, + true, + ); const worldCard = snapshot?.draftCards.find((card) => card.kind === 'world'); assert.ok(worldCard); @@ -352,8 +319,10 @@ test('phase3 ready session can execute draft_foundation and expose card detail', }); test('phase3 draft_foundation rejects not-ready session', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), autoAssetService: createFallbackAutoAssetService('not-ready'), @@ -373,8 +342,11 @@ test('phase3 draft_foundation rejects not-ready session', async () => { }); test('phase3 work summaries prefer compiled foundation draft fields', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository, rpgWorldProfileRepository } = + createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), autoAssetService: createFallbackAutoAssetService('summary'), @@ -397,7 +369,7 @@ test('phase3 work summaries prefer compiled foundation draft fields', async () = ); const items = await listCustomWorldWorkSummaries(userId, { - runtimeRepository, + rpgWorldProfiles: rpgWorldProfileRepository, customWorldAgentSessions: sessionStore, }); const draft = items.find((item) => item.sessionId === readySession.sessionId); @@ -423,8 +395,10 @@ test('phase3 work summaries prefer compiled foundation draft fields', async () = }); test('phase3 draft foundation still completes when auto asset generation fails', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const autoAssetService = new CustomWorldAgentAutoAssetService( createAutoAssetTestConfig('asset-failure'), async () => { diff --git a/server-node/src/services/customWorldAgentPhase4.test.ts b/server-node/src/services/customWorldAgentPhase4.test.ts index edf5c4db..a03c5c48 100644 --- a/server-node/src/services/customWorldAgentPhase4.test.ts +++ b/server-node/src/services/customWorldAgentPhase4.test.ts @@ -1,94 +1,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; -import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; 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'; -function createRuntimeRepositoryStub(): RuntimeRepositoryPort { - const sessionsByUser = new Map< - string, - Map - >(); - const profilesByUser = new Map[]>(); - - const getSessionBucket = (userId: string) => { - const existing = sessionsByUser.get(userId); - if (existing) { - return existing; - } - - const nextBucket = new Map(); - sessionsByUser.set(userId, nextBucket); - return nextBucket; - }; - - return { - async getSnapshot(_userId) { - return null; - }, - async putSnapshot(_userId, _payload) { - throw new Error('not implemented'); - }, - async deleteSnapshot(_userId) { - return undefined; - }, - async getSettings() { - return { - musicVolume: 0.42, - platformTheme: 'light', - }; - }, - async putSettings(_userId, settings) { - return settings; - }, - async listCustomWorldProfiles(userId) { - return [...(profilesByUser.get(userId) ?? [])]; - }, - async upsertCustomWorldProfile(userId, profileId, profile) { - const current = [...(profilesByUser.get(userId) ?? [])].filter( - (item) => String(item.id ?? '') !== profileId, - ); - current.unshift({ - ...profile, - id: profileId, - }); - profilesByUser.set(userId, current); - return current; - }, - async deleteCustomWorldProfile(userId, profileId) { - const current = [...(profilesByUser.get(userId) ?? [])].filter( - (item) => String(item.id ?? '') !== profileId, - ); - profilesByUser.set(userId, current); - return current; - }, - async listProfileSaveArchives() { - return []; - }, - async resumeProfileSaveArchive() { - return null; - }, - async listCustomWorldSessions(userId) { - return [...getSessionBucket(userId).values()]; - }, - async getCustomWorldSession(userId, sessionId) { - return getSessionBucket(userId).get(sessionId) ?? null; - }, - async upsertCustomWorldSession(userId, sessionId, session) { - getSessionBucket(userId).set( - sessionId, - JSON.parse(JSON.stringify(session)), - ); - return JSON.parse(JSON.stringify(session)); - }, - }; -} - async function waitForOperation( orchestrator: CustomWorldAgentOrchestrator, userId: string, @@ -167,8 +86,10 @@ async function createObjectRefiningSession( } test('phase4 update_draft_card writes back draft profile and recompiles summaries', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); @@ -228,8 +149,10 @@ test('phase4 update_draft_card writes back draft profile and recompiles summarie }); test('phase4 sync_result_profile writes result-page snapshot back into session draft chain', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); @@ -290,6 +213,15 @@ test('phase4 sync_result_profile writes result-page snapshot back into session d profile?.summary, '结果页已经把世界概述继续往沉船夜暗线收紧。', ); + assert.equal(snapshot?.resultPreview?.source, 'session_preview'); + assert.equal( + snapshot?.resultPreview?.preview.name, + '潮雾列岛·结果页精修版', + ); + assert.equal( + snapshot?.resultPreview?.preview.playerGoal, + '查清沉船夜与假航灯的真正操盘者。', + ); assert.equal(legacyResultProfile?.name, '潮雾列岛·结果页精修版'); assert.equal( legacyResultProfile?.playerGoal, @@ -305,8 +237,10 @@ test('phase4 sync_result_profile writes result-page snapshot back into session d }); test('phase4 sync_result_profile keeps existing foundation structure while updating summary snapshot', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); @@ -422,8 +356,10 @@ test('phase4 sync_result_profile keeps existing foundation structure while updat }); test('phase4 sync_result_profile also writes latest role and scene assets back into draft profile', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); @@ -578,8 +514,11 @@ test('phase4 sync_result_profile also writes latest role and scene assets back i }); test('phase4 generate_characters appends story npcs and updates work summary counts', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository, rpgWorldProfileRepository } = + createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); @@ -614,7 +553,7 @@ test('phase4 generate_characters appends story npcs and updates work summary cou ), ].length; const workItems = await listCustomWorldWorkSummaries(userId, { - runtimeRepository, + rpgWorldProfiles: rpgWorldProfileRepository, customWorldAgentSessions: sessionStore, }); const draftItem = workItems.find((item) => item.sessionId === session.sessionId); @@ -634,8 +573,10 @@ test('phase4 generate_characters appends story npcs and updates work summary cou }); test('phase4 generate_landmarks appends new landmark cards and checkpoints', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); @@ -675,25 +616,33 @@ test('phase4 generate_landmarks appends new landmark cards and checkpoints', asy }); test('phase4 work summaries exclude library draft entries after phase3 downgrade', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository, rpgWorldProfileRepository } = + createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); const userId = 'user-phase4-work-summary-phase3'; const session = await createObjectRefiningSession(orchestrator, userId); - await runtimeRepository.upsertCustomWorldProfile(userId, 'library-draft-1', { - id: 'library-draft-1', - name: '旧兼容草稿', - subtitle: '仍保留在作品库', - summary: '不应该继续出现在创作中心 works 聚合里。', - playableNpcs: [], - landmarks: [], - }); + await rpgWorldProfileRepository.upsertOwnProfile( + userId, + 'library-draft-1', + { + id: 'library-draft-1', + name: '旧兼容草稿', + subtitle: '仍保留在作品库', + summary: '不应该继续出现在创作中心 works 聚合里。', + playableNpcs: [], + landmarks: [], + }, + '玩家', + ); const workItems = await listCustomWorldWorkSummaries(userId, { - runtimeRepository, + rpgWorldProfiles: rpgWorldProfileRepository, customWorldAgentSessions: sessionStore, }); @@ -703,3 +652,54 @@ test('phase4 work summaries exclude library draft entries after phase3 downgrade false, ); }); + +test('phase4 work summaries hide published agent sessions from draft lane and keep published entry enterable', async () => { + const { rpgAgentSessionRepository, rpgWorldProfileRepository } = + createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + }); + const userId = 'user-phase4-work-summary-published'; + const session = await createObjectRefiningSession(orchestrator, userId); + + await sessionStore.replaceDerivedState(userId, session.sessionId, { + stage: 'published', + qualityFindings: [], + }); + await rpgWorldProfileRepository.upsertOwnProfile( + userId, + `agent-draft-${session.sessionId}`, + { + id: `agent-draft-${session.sessionId}`, + name: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '已发布版本。', + playableNpcs: [], + landmarks: [], + }, + '玩家', + ); + await rpgWorldProfileRepository.publishOwnProfile( + userId, + `agent-draft-${session.sessionId}`, + '玩家', + ); + + const workItems = await listCustomWorldWorkSummaries(userId, { + rpgWorldProfiles: rpgWorldProfileRepository, + customWorldAgentSessions: sessionStore, + }); + const draftItem = workItems.find((item) => item.sessionId === session.sessionId); + const publishedItem = workItems.find( + (item) => item.profileId === `agent-draft-${session.sessionId}`, + ); + + assert.equal(draftItem, undefined); + assert.equal(publishedItem?.status, 'published'); + assert.equal(publishedItem?.canEnterWorld, true); + assert.equal(publishedItem?.publishReady, true); + assert.equal(publishedItem?.blockerCount, 0); +}); diff --git a/server-node/src/services/customWorldAgentPhase5.test.ts b/server-node/src/services/customWorldAgentPhase5.test.ts index 6590f136..8ad74ed2 100644 --- a/server-node/src/services/customWorldAgentPhase5.test.ts +++ b/server-node/src/services/customWorldAgentPhase5.test.ts @@ -4,95 +4,15 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; -import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; import type { AppConfig } from '../config.js'; -import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; +import type { UserRepositoryPort } from '../repositories/userRepository.js'; import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js'; import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'; +import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js'; import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js'; -function createRuntimeRepositoryStub(): RuntimeRepositoryPort { - const sessionsByUser = new Map< - string, - Map - >(); - const profilesByUser = new Map[]>(); - - const getSessionBucket = (userId: string) => { - const existing = sessionsByUser.get(userId); - if (existing) { - return existing; - } - - const nextBucket = new Map(); - sessionsByUser.set(userId, nextBucket); - return nextBucket; - }; - - return { - async getSnapshot() { - return null; - }, - async putSnapshot(_userId, payload) { - return payload; - }, - async deleteSnapshot() { - return undefined; - }, - async getSettings() { - return { - musicVolume: 0.42, - platformTheme: 'light', - }; - }, - async putSettings(_userId, settings) { - return settings; - }, - async listCustomWorldProfiles(userId) { - return [...(profilesByUser.get(userId) ?? [])]; - }, - async upsertCustomWorldProfile(userId, profileId, profile) { - const current = [...(profilesByUser.get(userId) ?? [])].filter( - (item) => String(item.id ?? '') !== profileId, - ); - current.unshift({ - ...profile, - id: profileId, - }); - profilesByUser.set(userId, current); - return current; - }, - async deleteCustomWorldProfile(userId, profileId) { - const current = [...(profilesByUser.get(userId) ?? [])].filter( - (item) => String(item.id ?? '') !== profileId, - ); - profilesByUser.set(userId, current); - return current; - }, - async listProfileSaveArchives() { - return []; - }, - async resumeProfileSaveArchive() { - return null; - }, - async listCustomWorldSessions(userId) { - return [...getSessionBucket(userId).values()]; - }, - async getCustomWorldSession(userId, sessionId) { - return getSessionBucket(userId).get(sessionId) ?? null; - }, - async upsertCustomWorldSession(userId, sessionId, session) { - getSessionBucket(userId).set( - sessionId, - JSON.parse(JSON.stringify(session)), - ); - return JSON.parse(JSON.stringify(session)); - }, - }; -} - function createAutoAssetTestConfig(testName: string): AppConfig { const projectRoot = fs.mkdtempSync( path.join(os.tmpdir(), `genarrative-agent-phase5-${testName}-`), @@ -194,6 +114,36 @@ function createFallbackAutoAssetService(testName: string) { ); } +// 发布执行器当前通过 userRepository 读取作者展示名,这里用内存 stub 对齐主链接口。 +function createUserRepository(displayName = '测试玩家'): UserRepositoryPort { + const now = '2026-04-21T00:00:00.000Z'; + + return { + findByUsername: async () => null, + findByPhoneNumber: async () => null, + findById: async (userId) => ({ + id: userId, + username: null, + passwordHash: '', + tokenVersion: 1, + displayName, + loginProvider: 'password', + accountStatus: 'active', + phoneNumber: null, + phoneVerifiedAt: null, + createdAt: now, + updatedAt: now, + }), + create: async () => null, + createPhoneUser: async () => null, + createWechatPendingUser: async () => null, + activatePendingWechatUser: async () => null, + updatePhoneInfo: async () => null, + deleteUser: async () => undefined, + incrementTokenVersion: async () => null, + }; +} + async function waitForOperation( orchestrator: CustomWorldAgentOrchestrator, userId: string, @@ -279,9 +229,119 @@ async function createObjectRefiningSession( ))!; } +async function createPublishReadySession( + orchestrator: CustomWorldAgentOrchestrator, + sessionStore: CustomWorldAgentSessionStore, + userId: string, +) { + const session = await createObjectRefiningSession(orchestrator, userId); + const profile = normalizeFoundationDraftProfile(session.draftProfile); + + assert.ok(profile); + assert.ok(profile.playableNpcs.length > 0); + assert.ok(profile.storyNpcs.length > 0); + assert.ok(profile.landmarks.length > 0); + assert.ok(profile.sceneChapters.length > 0); + + const publishReadyProfile = { + ...(session.draftProfile as Record), + camp: { + ...(profile.camp ?? {}), + id: profile.camp?.id ?? 'camp-home', + name: profile.camp?.name ?? '归潮营地', + description: profile.camp?.description ?? '可供玩家整理线索的临时据点。', + imageSrc: '/generated/camp/publish-ready.png', + generatedSceneAssetId: 'scene-camp-publish-ready', + generatedScenePrompt: '潮雾营地发布正式图', + generatedSceneModel: 'test-scene-model', + }, + playableNpcs: profile.playableNpcs.map((entry, index) => ({ + ...entry, + imageSrc: + entry.imageSrc || `/generated/playable/publish-ready-${index + 1}.png`, + generatedVisualAssetId: + entry.generatedVisualAssetId || `visual-playable-publish-${index + 1}`, + generatedAnimationSetId: + entry.generatedAnimationSetId || `anim-playable-publish-${index + 1}`, + })), + storyNpcs: profile.storyNpcs.map((entry, index) => ({ + ...entry, + imageSrc: + entry.imageSrc || `/generated/story/publish-ready-${index + 1}.png`, + generatedVisualAssetId: + entry.generatedVisualAssetId || `visual-story-publish-${index + 1}`, + generatedAnimationSetId: + entry.generatedAnimationSetId || `anim-story-publish-${index + 1}`, + })), + landmarks: profile.landmarks.map((entry, index) => ({ + ...entry, + imageSrc: + entry.imageSrc || `/generated/landmark/publish-ready-${index + 1}.png`, + generatedSceneAssetId: + entry.generatedSceneAssetId || `scene-landmark-publish-${index + 1}`, + generatedScenePrompt: + entry.generatedScenePrompt || `地点 ${entry.name} 的正式场景图`, + generatedSceneModel: + entry.generatedSceneModel || 'test-scene-model', + })), + sceneChapters: profile.sceneChapters.map((chapter) => ({ + ...chapter, + linkedThreadIds: + chapter.linkedThreadIds.length > 0 + ? chapter.linkedThreadIds + : [profile.threads[0]?.id ?? 'thread-publish-ready'], + acts: chapter.acts.map((act, index) => ({ + ...act, + encounterNpcIds: + act.encounterNpcIds.length > 0 + ? act.encounterNpcIds + : [profile.storyNpcs[0]?.id ?? profile.playableNpcs[0]?.id ?? 'role-publish-ready'], + primaryNpcId: + act.primaryNpcId || + act.encounterNpcIds[0] || + profile.storyNpcs[0]?.id || + profile.playableNpcs[0]?.id || + 'role-publish-ready', + backgroundImageSrc: + act.backgroundImageSrc || + `/generated/scene/publish-ready-${chapter.id}-${index + 1}.png`, + backgroundAssetId: + act.backgroundAssetId || `scene-act-publish-${chapter.id}-${index + 1}`, + })), + })), + chapters: profile.chapters, + } satisfies Record; + + await sessionStore.replaceDerivedState(userId, session.sessionId, { + stage: 'ready_to_publish', + draftProfile: publishReadyProfile, + draftCards: session.draftCards, + qualityFindings: [], + focusCardId: session.focusCardId, + assetCoverage: session.assetCoverage, + }); + + const publishReadySession = await orchestrator.getSessionSnapshot( + userId, + session.sessionId, + ); + + assert.equal(publishReadySession?.stage, 'ready_to_publish'); + assert.equal( + publishReadySession?.supportedActions.find( + (entry) => entry.action === 'publish_world', + )?.enabled, + true, + ); + + return publishReadySession!; +} + test('phase5 generate_role_assets only allows a single role and moves session into visual_refining', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), autoAssetService: createFallbackAutoAssetService('generate-role-assets'), @@ -317,6 +377,18 @@ test('phase5 generate_role_assets only allows a single role and moves session in assert.equal(operation?.status, 'completed'); assert.equal(snapshot?.stage, 'visual_refining'); assert.equal(snapshot?.focusCardId, characterIds[0]); + assert.equal( + snapshot?.supportedActions?.find( + (entry) => entry.action === 'generate_role_assets', + )?.enabled, + true, + ); + assert.equal( + snapshot?.supportedActions?.find( + (entry) => entry.action === 'sync_role_assets', + )?.enabled, + true, + ); assert.ok( snapshot?.messages.some( (message) => @@ -331,8 +403,10 @@ test('phase5 generate_role_assets only allows a single role and moves session in }); test('phase5 sync_role_assets writes fields back, updates coverage and recompiles character cards', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), autoAssetService: createFallbackAutoAssetService('sync-role-assets'), @@ -409,7 +483,7 @@ test('phase5 sync_role_assets writes fields back, updates coverage and recompile ?.idle?.basePath, '/generated/characters/shenli/idle', ); - const syncedSkillIds = syncedRole?.skills.map((skill) => skill.id) ?? []; + const syncedSkillIds = syncedRole?.skills?.map((skill) => skill.id) ?? []; assert.ok(syncedSkillIds.length > 0); assert.equal(syncedAssetSummary?.status, 'animations_ready'); assert.deepEqual( @@ -426,3 +500,484 @@ test('phase5 sync_role_assets writes fields back, updates coverage and recompile ); assert.ok((latestRecord?.checkpoints.length ?? 0) >= 2); }); + +test('phase5 publish_world persists published profile and moves session into published stage', async () => { + const { rpgAgentSessionRepository, rpgWorldProfileRepository } = + createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + autoAssetService: createFallbackAutoAssetService('publish-world'), + rpgWorldProfileRepository, + userRepository: createUserRepository('发布测试玩家'), + }); + const userId = 'user-phase5-publish-world'; + const session = await createPublishReadySession( + orchestrator, + sessionStore, + userId, + ); + + const response = await orchestrator.executeAction(userId, session.sessionId, { + action: 'publish_world', + }); + const operation = await waitForOperation( + orchestrator, + userId, + session.sessionId, + response.operation.operationId, + ); + const snapshot = await orchestrator.getSessionSnapshot( + userId, + session.sessionId, + ); + const profiles = await rpgWorldProfileRepository.listOwnProfiles(userId); + const publishedEntry = profiles.find( + (entry) => entry.profileId === `agent-draft-${session.sessionId}`, + ); + const latestRecord = await sessionStore.get(userId, session.sessionId); + + assert.equal(operation?.status, 'completed'); + assert.equal(snapshot?.stage, 'published'); + assert.equal(snapshot?.resultPreview?.publishReady, true); + assert.equal(snapshot?.resultPreview?.canEnterWorld, true); + assert.deepEqual(snapshot?.resultPreview?.blockers ?? [], []); + assert.equal( + snapshot?.supportedActions.find( + (entry) => entry.action === 'publish_world', + )?.enabled, + false, + ); + assert.equal(publishedEntry?.visibility, 'published'); + assert.equal(publishedEntry?.authorDisplayName, '发布测试玩家'); + assert.equal(publishedEntry?.profile.id, `agent-draft-${session.sessionId}`); + assert.ok( + snapshot?.messages.some( + (message) => + message.kind === 'action_result' && + message.text.includes('已正式发布'), + ), + ); + assert.ok( + latestRecord?.checkpoints.some( + (checkpoint) => + checkpoint.label.includes('发布世界') && + checkpoint.snapshot?.stage === 'published', + ), + ); +}); + +test('phase5 generate_scene_assets prepares scene studio and sync_scene_assets writes back camp asset fields', async () => { + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + autoAssetService: createFallbackAutoAssetService('sync-scene-assets'), + }); + const userId = 'user-phase5-sync-scene-assets'; + const session = await createObjectRefiningSession(orchestrator, userId); + const campCard = session.draftCards.find((card) => card.kind === 'camp'); + + assert.ok(campCard); + + const prepareResponse = await orchestrator.executeAction( + userId, + session.sessionId, + { + action: 'generate_scene_assets', + sceneIds: [campCard!.id], + }, + ); + const prepareOperation = await waitForOperation( + orchestrator, + userId, + session.sessionId, + prepareResponse.operation.operationId, + ); + const preparedSnapshot = await orchestrator.getSessionSnapshot( + userId, + session.sessionId, + ); + + assert.equal(prepareOperation?.status, 'completed'); + assert.equal(preparedSnapshot?.stage, 'visual_refining'); + assert.equal(preparedSnapshot?.focusCardId, campCard!.id); + assert.ok( + preparedSnapshot?.messages.some( + (message) => + message.kind === 'action_result' && + message.text.includes('场景图工坊'), + ), + ); + + const response = await orchestrator.executeAction(userId, session.sessionId, { + action: 'sync_scene_assets', + sceneId: campCard!.id, + sceneKind: 'camp', + imageSrc: '/generated/scenes/camp-home.png', + generatedSceneAssetId: 'scene-camp-home-1', + generatedScenePrompt: '潮雾中的灯塔营地', + generatedSceneModel: 'test-scene-model', + }); + const operation = await waitForOperation( + orchestrator, + userId, + session.sessionId, + response.operation.operationId, + ); + const snapshot = await orchestrator.getSessionSnapshot( + userId, + session.sessionId, + ); + const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile); + const latestRecord = await sessionStore.get(userId, session.sessionId); + + assert.equal(operation?.status, 'completed'); + assert.equal(snapshot?.stage, 'visual_refining'); + assert.equal(snapshot?.focusCardId, campCard!.id); + assert.equal(profile?.camp?.imageSrc, '/generated/scenes/camp-home.png'); + assert.equal(profile?.camp?.generatedSceneAssetId, 'scene-camp-home-1'); + assert.equal(profile?.camp?.generatedScenePrompt, '潮雾中的灯塔营地'); + assert.equal(profile?.camp?.generatedSceneModel, 'test-scene-model'); + assert.ok( + profile?.sceneChapters.every((chapter) => + chapter.sceneId === campCard!.id + ? chapter.acts.every( + (act) => + act.backgroundImageSrc === '/generated/scenes/camp-home.png' && + act.backgroundAssetId === 'scene-camp-home-1', + ) + : true, + ), + ); + assert.ok( + snapshot?.messages.some( + (message) => + message.kind === 'action_result' && + message.text.includes('场景图写回草稿'), + ), + ); + assert.ok( + latestRecord?.checkpoints.some( + (checkpoint) => + checkpoint.label.includes('同步场景资产') && Boolean(checkpoint.snapshot), + ), + ); +}); + +test('phase5 expand_long_tail appends characters and landmarks then moves into long_tail_review', async () => { + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + autoAssetService: createFallbackAutoAssetService('expand-long-tail'), + }); + const userId = 'user-phase5-expand-long-tail'; + const session = await createObjectRefiningSession(orchestrator, userId); + const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!; + const baselineCharacterCount = [ + ...new Set( + [...baselineProfile.playableNpcs, ...baselineProfile.storyNpcs].map( + (entry) => entry.id, + ), + ), + ].length; + const baselineLandmarkCount = baselineProfile.landmarks.length; + + const response = await orchestrator.executeAction(userId, session.sessionId, { + action: 'expand_long_tail', + }); + const operation = await waitForOperation( + orchestrator, + userId, + session.sessionId, + response.operation.operationId, + ); + const snapshot = await orchestrator.getSessionSnapshot( + userId, + session.sessionId, + ); + const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile)!; + const nextCharacterCount = [ + ...new Set( + [...profile.playableNpcs, ...profile.storyNpcs].map((entry) => entry.id), + ), + ].length; + const latestRecord = await sessionStore.get(userId, session.sessionId); + + assert.equal(operation?.status, 'completed'); + assert.equal(snapshot?.stage, 'long_tail_review'); + assert.ok(nextCharacterCount >= baselineCharacterCount + 2); + assert.ok(profile.landmarks.length >= baselineLandmarkCount + 2); + assert.ok( + snapshot?.messages.some( + (message) => + message.kind === 'action_result' && + message.text.includes('长尾角色'), + ), + ); + assert.ok( + latestRecord?.checkpoints.some( + (checkpoint) => + checkpoint.label.includes('扩展长尾') && Boolean(checkpoint.snapshot), + ), + ); +}); + +test('phase5 publish_world blocks incomplete draft and publishes complete world into repository', async () => { + const { rpgAgentSessionRepository, rpgWorldProfileRepository } = + createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + autoAssetService: createFallbackAutoAssetService('publish-world'), + rpgWorldProfileRepository, + userRepository: createUserRepository(), + }); + const userId = 'user-phase5-publish-world'; + const session = await createObjectRefiningSession(orchestrator, userId); + + const blockedResponse = await orchestrator.executeAction( + userId, + session.sessionId, + { + action: 'publish_world', + }, + ); + const blockedOperation = await waitForOperation( + orchestrator, + userId, + session.sessionId, + blockedResponse.operation.operationId, + ); + const blockedSnapshot = await orchestrator.getSessionSnapshot( + userId, + session.sessionId, + ); + + assert.equal(blockedOperation?.status, 'failed'); + assert.ok( + blockedSnapshot?.messages.some( + (message) => + message.kind === 'warning' && message.text.includes('当前世界还不能发布'), + ), + ); + + const profile = normalizeFoundationDraftProfile(blockedSnapshot?.draftProfile)!; + const roleIds = [...profile.playableNpcs, ...profile.storyNpcs].map( + (entry) => entry.id, + ); + + for (const roleId of roleIds) { + const syncRoleResponse = await orchestrator.executeAction( + userId, + session.sessionId, + { + action: 'sync_role_assets', + roleId, + portraitPath: `/generated/characters/${roleId}.png`, + generatedVisualAssetId: `visual-${roleId}`, + generatedAnimationSetId: `animation-${roleId}`, + animationMap: { + run: { basePath: `/generated/characters/${roleId}/run` }, + attack: { basePath: `/generated/characters/${roleId}/attack` }, + }, + }, + ); + await waitForOperation( + orchestrator, + userId, + session.sessionId, + syncRoleResponse.operation.operationId, + ); + } + + const latestSnapshot = await orchestrator.getSessionSnapshot( + userId, + session.sessionId, + ); + const latestProfile = normalizeFoundationDraftProfile(latestSnapshot?.draftProfile)!; + const sceneTargets = [ + { + sceneId: latestProfile.camp?.id ?? 'camp-home', + sceneKind: 'camp' as const, + }, + ...latestProfile.landmarks.map((entry) => ({ + sceneId: entry.id, + sceneKind: 'landmark' as const, + })), + ]; + + for (const sceneTarget of sceneTargets) { + const syncSceneResponse = await orchestrator.executeAction( + userId, + session.sessionId, + { + action: 'sync_scene_assets', + sceneId: sceneTarget.sceneId, + sceneKind: sceneTarget.sceneKind, + imageSrc: `/generated/scenes/${sceneTarget.sceneId}.png`, + generatedSceneAssetId: `scene-${sceneTarget.sceneId}`, + generatedScenePrompt: `${sceneTarget.sceneId} 场景图`, + generatedSceneModel: 'test-scene-model', + }, + ); + await waitForOperation( + orchestrator, + userId, + session.sessionId, + syncSceneResponse.operation.operationId, + ); + } + + const publishResponse = await orchestrator.executeAction( + userId, + session.sessionId, + { + action: 'publish_world', + }, + ); + const publishOperation = await waitForOperation( + orchestrator, + userId, + session.sessionId, + publishResponse.operation.operationId, + ); + const publishedSnapshot = await orchestrator.getSessionSnapshot( + userId, + session.sessionId, + ); + const libraryEntries = await rpgWorldProfileRepository.listOwnProfiles(userId); + const publishedEntry = libraryEntries.find( + (entry) => entry.visibility === 'published', + ); + const latestRecord = await sessionStore.get(userId, session.sessionId); + + assert.equal(blockedOperation?.status, 'failed'); + assert.match(blockedOperation?.error ?? '', /缺少正式主图|缺少正式场景图|缺少章节草稿/u); + assert.equal(blockedSnapshot?.resultPreview?.publishReady, false); + assert.ok((blockedSnapshot?.resultPreview?.blockers?.length ?? 0) > 0); + assert.equal(publishOperation?.status, 'completed'); + assert.equal(publishedSnapshot?.stage, 'published'); + assert.equal(publishedSnapshot?.resultPreview?.publishReady, true); + assert.equal(publishedSnapshot?.resultPreview?.canEnterWorld, true); + assert.ok(publishedSnapshot?.qualityFindings.every((entry) => entry.severity !== 'blocker')); + assert.ok( + publishedSnapshot?.messages.some( + (message) => + message.kind === 'action_result' && + message.text.includes('已正式发布'), + ), + ); + assert.ok(publishedEntry); + assert.equal(publishedEntry?.profileId, `agent-draft-${session.sessionId}`); + assert.equal(publishedEntry?.authorDisplayName, '测试玩家'); + assert.ok( + latestRecord?.checkpoints.some( + (checkpoint) => + checkpoint.label.includes('发布世界') && + checkpoint.snapshot?.stage === 'published', + ), + ); +}); + +test('phase5 revert_checkpoint restores previous draft snapshot', async () => { + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + autoAssetService: createFallbackAutoAssetService('revert-checkpoint'), + }); + const userId = 'user-phase5-revert-checkpoint'; + const session = await createObjectRefiningSession(orchestrator, userId); + + const updateResponse = await orchestrator.executeAction( + userId, + session.sessionId, + { + action: 'update_draft_card', + cardId: 'world-foundation', + sections: [ + { + sectionId: 'summary', + value: '回滚测试摘要版本', + }, + ], + }, + ); + await waitForOperation( + orchestrator, + userId, + session.sessionId, + updateResponse.operation.operationId, + ); + + const afterUpdateRecord = await sessionStore.get(userId, session.sessionId); + const restorableCheckpoint = [...(afterUpdateRecord?.checkpoints ?? [])] + .reverse() + .find((checkpoint) => Boolean(checkpoint.snapshot)); + + assert.ok(restorableCheckpoint); + + const syncRoleResponse = await orchestrator.executeAction( + userId, + session.sessionId, + { + action: 'sync_role_assets', + roleId: + normalizeFoundationDraftProfile( + (await orchestrator.getSessionSnapshot(userId, session.sessionId)) + ?.draftProfile, + )?.playableNpcs[0]?.id ?? 'unknown-role', + portraitPath: '/generated/characters/revert-test.png', + generatedVisualAssetId: 'visual-revert-test', + generatedAnimationSetId: 'animation-revert-test', + animationMap: { + run: { basePath: '/generated/characters/revert-test/run' }, + attack: { basePath: '/generated/characters/revert-test/attack' }, + }, + }, + ); + await waitForOperation( + orchestrator, + userId, + session.sessionId, + syncRoleResponse.operation.operationId, + ); + + const response = await orchestrator.executeAction(userId, session.sessionId, { + action: 'revert_checkpoint', + checkpointId: restorableCheckpoint!.checkpointId, + }); + const operation = await waitForOperation( + orchestrator, + userId, + session.sessionId, + response.operation.operationId, + ); + const snapshot = await orchestrator.getSessionSnapshot( + userId, + session.sessionId, + ); + const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile); + + assert.equal(operation?.status, 'completed'); + assert.equal(profile?.summary, '回滚测试摘要版本'); + assert.ok( + snapshot?.messages.some( + (message) => + message.kind === 'action_result' && + message.text.includes('已恢复到检查点'), + ), + ); +}); diff --git a/server-node/src/services/customWorldAgentPublishGateService.ts b/server-node/src/services/customWorldAgentPublishGateService.ts new file mode 100644 index 00000000..64c9d1e0 --- /dev/null +++ b/server-node/src/services/customWorldAgentPublishGateService.ts @@ -0,0 +1,141 @@ +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 | 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; + 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, + }; + } +} diff --git a/server-node/src/services/customWorldAgentPublishService.ts b/server-node/src/services/customWorldAgentPublishService.ts new file mode 100644 index 00000000..f3fa02ae --- /dev/null +++ b/server-node/src/services/customWorldAgentPublishService.ts @@ -0,0 +1,410 @@ +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 { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function toRecordArray(value: unknown) { + return Array.isArray(value) + ? value.filter( + (item): item is Record => + 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, + 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; +} + +function buildRuntimeLandmarkFromDraft( + draftLandmark: Record, + storyNpcIdSet: Set, + 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; +} + +function buildRuntimeCampFromDraft(draftCamp: Record | 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; +} + +function buildRuntimeSceneChaptersFromDraft( + draftProfile: Record, + storyNpcIdSet: Set, + landmarkIdSet: Set, +) { + 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; + }) + .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; + }) + .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; +} + +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, + }; + } +} diff --git a/server-node/src/services/customWorldAgentPublishingService.ts b/server-node/src/services/customWorldAgentPublishingService.ts new file mode 100644 index 00000000..4577f38e --- /dev/null +++ b/server-node/src/services/customWorldAgentPublishingService.ts @@ -0,0 +1,256 @@ +import type { CustomWorldProfileRecord } from '../../../packages/shared/src/contracts/runtime.js'; +import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js'; +import { buildRpgCreationPreviewProfileFromDraftProfile } from './rpgCreationPreviewProfileBuilder.js'; +import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; + +function toText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function hasGeneratedSceneAsset( + value: unknown, +) { + return Boolean(toText((value as Record | 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 | 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; + 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, + 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, + }; + } +} diff --git a/server-node/src/services/customWorldAgentQualityGateService.ts b/server-node/src/services/customWorldAgentQualityGateService.ts new file mode 100644 index 00000000..856cd919 --- /dev/null +++ b/server-node/src/services/customWorldAgentQualityGateService.ts @@ -0,0 +1,88 @@ +import type { + CustomWorldAgentSessionSnapshot, + CustomWorldAgentStage, + CustomWorldAssetCoverageSummary, +} from '../../../packages/shared/src/contracts/customWorldAgent.js'; +import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; + +export type CustomWorldAgentQualityFinding = + CustomWorldAgentSessionSnapshot['qualityFindings'][number]; + +const QUALITY_GATE_STAGES = new Set([ + 'object_refining', + 'visual_refining', + 'long_tail_review', + 'ready_to_publish', +]); + +export class CustomWorldAgentQualityGateService { + // 当前先把最核心的阻断项和提醒项独立收口,后续 publish gate 可以直接复用同一套 finding。 + buildQualityFindings(params: { + draftProfile: unknown; + assetCoverage?: CustomWorldAssetCoverageSummary | null; + stage?: CustomWorldAgentStage; + }): CustomWorldAgentQualityFinding[] { + if (params.stage && !QUALITY_GATE_STAGES.has(params.stage)) { + return []; + } + + const profile = normalizeFoundationDraftProfile(params.draftProfile); + if (!profile) { + return []; + } + + const findings: CustomWorldAgentQualityFinding[] = []; + const totalRoleCount = [ + ...new Set( + [...profile.playableNpcs, ...profile.storyNpcs].map((entry) => entry.id), + ), + ].length; + + if (totalRoleCount === 0) { + findings.push({ + id: 'missing-core-roles', + severity: 'blocker', + code: 'missing_core_roles', + message: '当前世界底稿还没有任何角色,暂时无法进入发布前收口阶段。', + }); + } + + if (profile.landmarks.length === 0) { + findings.push({ + id: 'missing-core-landmarks', + severity: 'blocker', + code: 'missing_core_landmarks', + message: '当前世界底稿还没有任何地点,至少需要补出一处关键地点。', + }); + } + + if (!profile.playerGoal.trim()) { + findings.push({ + id: 'missing-player-goal', + severity: 'warning', + code: 'missing_player_goal', + message: '玩家目标还不够明确,后续进入结果页后建议优先补齐可执行目标。', + }); + } + + if (params.assetCoverage && !params.assetCoverage.allRoleAssetsReady) { + findings.push({ + id: 'role-assets-pending', + severity: 'warning', + code: 'role_assets_pending', + message: '仍有角色资产未完全补齐,结果页可继续补主图与动作资源。', + }); + } + + if (params.assetCoverage && !params.assetCoverage.allSceneAssetsReady) { + findings.push({ + id: 'scene-assets-pending', + severity: 'warning', + code: 'scene_assets_pending', + message: '仍有场景分幕图未补齐,后续结果页进入发布前需要继续完善。', + }); + } + + return findings; + } +} diff --git a/server-node/src/services/customWorldAgentRepositoryTestHelpers.ts b/server-node/src/services/customWorldAgentRepositoryTestHelpers.ts new file mode 100644 index 00000000..a6bcb094 --- /dev/null +++ b/server-node/src/services/customWorldAgentRepositoryTestHelpers.ts @@ -0,0 +1,305 @@ +import type { + CustomWorldGalleryCard, + CustomWorldLibraryEntry, + CustomWorldProfileRecord, + CustomWorldSessionRecord, +} from '../../../packages/shared/src/contracts/runtime.js'; +import { extractCustomWorldLibraryMetadata } from '../repositories/customWorldLibraryMetadata.js'; +import type { RpgAgentSessionRepositoryPort } from '../repositories/RpgAgentSessionRepository.js'; +import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js'; + +type StoredProfileEntry = CustomWorldLibraryEntry; +type SeedSessionRecord = CustomWorldSessionRecord & { userId: string }; + +function cloneRepositoryValue(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function ensureProfileRecord( + profileId: string, + profile: Record, +): CustomWorldProfileRecord { + return { + ...cloneRepositoryValue(profile), + id: profileId, + } as CustomWorldProfileRecord; +} + +function buildProfileEntry(params: { + userId: string; + profileId: string; + profile: Record; + authorDisplayName: string; + visibility: 'draft' | 'published'; + updatedAt: string; + publishedAt: string | null; +}) { + const profileRecord = ensureProfileRecord(params.profileId, params.profile); + const metadata = extractCustomWorldLibraryMetadata(profileRecord); + + return { + ownerUserId: params.userId, + profileId: params.profileId, + profile: profileRecord, + visibility: params.visibility, + publishedAt: params.visibility === 'published' ? params.publishedAt : null, + updatedAt: params.updatedAt, + authorDisplayName: params.authorDisplayName || '玩家', + worldName: metadata.worldName, + subtitle: metadata.subtitle, + summaryText: metadata.summaryText, + coverImageSrc: metadata.coverImageSrc, + themeMode: metadata.themeMode, + playableNpcCount: metadata.playableNpcCount, + landmarkCount: metadata.landmarkCount, + } satisfies StoredProfileEntry; +} + +function toGalleryCard(entry: StoredProfileEntry): CustomWorldGalleryCard { + const { profile: _profile, ...card } = entry; + return cloneRepositoryValue(card); +} + +function sortEntriesByUpdatedAt(entries: T[]) { + return [...entries].sort((left, right) => + right.updatedAt.localeCompare(left.updatedAt), + ); +} + +/** + * 这组内存仓储 helper 让 phase2~5 与 works 集成测试直接依赖工作包 F 拆出来的领域端口, + * 避免继续把 RuntimeRepositoryPort 当成 session/profile 的测试替身。 + */ +export function createInMemoryRpgWorldRepositoryPorts(options?: { + sessionRecords?: SeedSessionRecord[]; + profileEntries?: Array>; +}) { + const sessionsByUser = new Map>(); + const profilesByUser = new Map>(); + + const ensureSessionBucket = (userId: string) => { + const currentBucket = sessionsByUser.get(userId); + if (currentBucket) { + return currentBucket; + } + + const nextBucket = new Map(); + sessionsByUser.set(userId, nextBucket); + return nextBucket; + }; + + const ensureProfileBucket = (userId: string) => { + const currentBucket = profilesByUser.get(userId); + if (currentBucket) { + return currentBucket; + } + + const nextBucket = new Map(); + profilesByUser.set(userId, nextBucket); + return nextBucket; + }; + + const listOwnEntries = (userId: string) => + sortEntriesByUpdatedAt([...ensureProfileBucket(userId).values()]).map((entry) => + cloneRepositoryValue(entry), + ); + + options?.sessionRecords?.forEach((record) => { + ensureSessionBucket(record.userId).set( + record.sessionId, + cloneRepositoryValue(record), + ); + }); + + options?.profileEntries?.forEach((entry) => { + ensureProfileBucket(entry.ownerUserId).set( + entry.profileId, + cloneRepositoryValue(entry), + ); + }); + + const rpgAgentSessionRepository: RpgAgentSessionRepositoryPort = { + async listSessions(userId: string) { + return sortEntriesByUpdatedAt([...ensureSessionBucket(userId).values()]).map( + (record) => cloneRepositoryValue(record), + ); + }, + + async getSession(userId: string, sessionId: string) { + const record = ensureSessionBucket(userId).get(sessionId) ?? null; + return record ? cloneRepositoryValue(record) : null; + }, + + async upsertSession( + userId: string, + sessionId: string, + session: CustomWorldSessionRecord, + ) { + const nextSession = cloneRepositoryValue({ + ...session, + userId, + sessionId, + }); + ensureSessionBucket(userId).set(sessionId, nextSession); + return cloneRepositoryValue(nextSession); + }, + }; + + const rpgWorldProfileRepository: RpgWorldProfileRepositoryPort = { + async listOwnProfiles(userId: string) { + return listOwnEntries(userId); + }, + + async upsertOwnProfile( + userId: string, + profileId: string, + profile: Record, + authorDisplayName: string, + ) { + const bucket = ensureProfileBucket(userId); + const currentEntry = bucket.get(profileId); + const now = new Date().toISOString(); + const nextEntry = buildProfileEntry({ + userId, + profileId, + profile, + authorDisplayName: + authorDisplayName || currentEntry?.authorDisplayName || '玩家', + visibility: currentEntry?.visibility ?? 'draft', + updatedAt: now, + publishedAt: + currentEntry?.visibility === 'published' + ? currentEntry.publishedAt || now + : null, + }); + + bucket.set(profileId, nextEntry); + + return { + entry: cloneRepositoryValue(nextEntry), + entries: await listOwnEntries(userId), + }; + }, + + async syncProfileFromSnapshot( + userId: string, + profileId: string, + profile: Record, + syncedAt: string, + ) { + const bucket = ensureProfileBucket(userId); + const currentEntry = bucket.get(profileId); + bucket.set( + profileId, + buildProfileEntry({ + userId, + profileId, + profile, + authorDisplayName: currentEntry?.authorDisplayName || '玩家', + visibility: currentEntry?.visibility ?? 'draft', + updatedAt: syncedAt, + publishedAt: + currentEntry?.visibility === 'published' + ? currentEntry.publishedAt || syncedAt + : null, + }), + ); + }, + + async softDeleteOwnProfile(userId: string, profileId: string) { + ensureProfileBucket(userId).delete(profileId); + return listOwnEntries(userId); + }, + + async publishOwnProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ) { + const bucket = ensureProfileBucket(userId); + const currentEntry = bucket.get(profileId); + if (!currentEntry) { + return null; + } + + const now = new Date().toISOString(); + const nextEntry = buildProfileEntry({ + userId, + profileId, + profile: currentEntry.profile, + authorDisplayName: + authorDisplayName || currentEntry.authorDisplayName || '玩家', + visibility: 'published', + updatedAt: now, + publishedAt: now, + }); + bucket.set(profileId, nextEntry); + + return { + entry: cloneRepositoryValue(nextEntry), + entries: await listOwnEntries(userId), + }; + }, + + async unpublishOwnProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ) { + const bucket = ensureProfileBucket(userId); + const currentEntry = bucket.get(profileId); + if (!currentEntry) { + return null; + } + + const now = new Date().toISOString(); + const nextEntry = buildProfileEntry({ + userId, + profileId, + profile: currentEntry.profile, + authorDisplayName: + authorDisplayName || currentEntry.authorDisplayName || '玩家', + visibility: 'draft', + updatedAt: now, + publishedAt: null, + }); + bucket.set(profileId, nextEntry); + + return { + entry: cloneRepositoryValue(nextEntry), + entries: await listOwnEntries(userId), + }; + }, + + async listPublishedGallery() { + return [...profilesByUser.values()] + .flatMap((bucket) => [...bucket.values()]) + .filter((entry) => entry.visibility === 'published') + .sort((left, right) => { + const publishedAtDiff = (right.publishedAt || '').localeCompare( + left.publishedAt || '', + ); + if (publishedAtDiff !== 0) { + return publishedAtDiff; + } + + return right.updatedAt.localeCompare(left.updatedAt); + }) + .map((entry) => toGalleryCard(entry)); + }, + + async getPublishedGalleryDetail(ownerUserId: string, profileId: string) { + const entry = ensureProfileBucket(ownerUserId).get(profileId); + if (!entry || entry.visibility !== 'published') { + return null; + } + + return cloneRepositoryValue(entry); + }, + }; + + return { + rpgAgentSessionRepository, + rpgWorldProfileRepository, + }; +} diff --git a/server-node/src/services/customWorldAgentResultSyncService.test.ts b/server-node/src/services/customWorldAgentResultSyncService.test.ts new file mode 100644 index 00000000..af20b40a --- /dev/null +++ b/server-node/src/services/customWorldAgentResultSyncService.test.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createRpgAgentFoundationDraftProfileFixture, + createRpgCreationPublishedProfileFixture, +} from '../../../packages/shared/src/contracts/rpgCreationFixtures.js'; +import { CustomWorldAgentResultSyncService } from './customWorldAgentResultSyncService.js'; + +test('result sync service only writes summary fields and matching asset confirmations back into draft profile', () => { + const service = new CustomWorldAgentResultSyncService(); + const currentDraftProfile = createRpgAgentFoundationDraftProfileFixture(); + const resultProfile = createRpgCreationPublishedProfileFixture(); + const nextDraftProfile = service.syncResultProfileIntoDraftProfile({ + currentDraftProfile: currentDraftProfile as unknown as Record, + resultProfile, + }); + + assert.equal(nextDraftProfile.name, resultProfile.name); + assert.equal(nextDraftProfile.summary, resultProfile.summary); + assert.equal( + ( + nextDraftProfile.playableNpcs as Array<{ + generatedAnimationSetId?: string | null; + }> + )[0]?.generatedAnimationSetId, + 'animation-set-playable-1', + ); + assert.equal( + ( + nextDraftProfile.landmarks as Array<{ + imageSrc?: string | null; + }> + )[0]?.imageSrc, + '/generated-custom-world-scenes/landmark-1/latest-scene.png', + ); + assert.equal( + ( + nextDraftProfile.sceneChapters as Array<{ + acts?: Array<{ backgroundAssetId?: string | null }>; + }> + )[0]?.acts?.[0]?.backgroundAssetId, + 'scene-asset-runtime', + ); +}); + +test('result sync service keeps existing foundation structure when result profile carries unmatched runtime-only entities', () => { + const service = new CustomWorldAgentResultSyncService(); + const currentDraftProfile = createRpgAgentFoundationDraftProfileFixture(); + const nextDraftProfile = service.syncResultProfileIntoDraftProfile({ + currentDraftProfile: currentDraftProfile as unknown as Record, + resultProfile: { + ...createRpgCreationPublishedProfileFixture(), + playableNpcs: [ + { + id: 'runtime-only-role', + name: '运行时临时角色', + title: '结果页临时角色', + role: '测试角色', + description: '不应覆盖 foundation draft。', + backstory: '测试', + personality: '冷静', + motivation: '测试', + combatStyle: '观察', + initialAffinity: 0, + relationshipHooks: [], + tags: [], + skills: [], + initialItems: [], + backstoryReveal: { + publicSummary: '测试', + privateChatUnlockAffinity: 0, + chapters: [], + }, + }, + ], + storyNpcs: [], + landmarks: [ + { + id: 'runtime-only-landmark', + name: '运行时临时地点', + description: '不应覆盖 foundation draft。', + dangerLevel: 'low', + sceneNpcIds: [], + connections: [], + }, + ], + sceneChapterBlueprints: [], + }, + }); + + assert.equal( + ( + nextDraftProfile.playableNpcs as Array<{ id?: string; name?: string }> + )[0]?.id, + 'playable-1', + ); + assert.equal( + ( + nextDraftProfile.playableNpcs as Array<{ id?: string; name?: string }> + )[0]?.name, + '沈砺', + ); + assert.equal( + ( + nextDraftProfile.landmarks as Array<{ id?: string; name?: string }> + )[0]?.id, + 'landmark-1', + ); + assert.equal( + ( + nextDraftProfile.legacyResultProfile as { + playableNpcs?: Array<{ id?: string }>; + } + ).playableNpcs?.[0]?.id, + 'runtime-only-role', + ); +}); diff --git a/server-node/src/services/customWorldAgentResultSyncService.ts b/server-node/src/services/customWorldAgentResultSyncService.ts new file mode 100644 index 00000000..8fb05b7e --- /dev/null +++ b/server-node/src/services/customWorldAgentResultSyncService.ts @@ -0,0 +1,150 @@ +import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js'; + +function toText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function toRecordArray(value: unknown) { + return Array.isArray(value) + ? value.filter((item): item is Record => isRecord(item)) + : []; +} + +function cloneJsonRecord(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function syncRoleAssetsFromResultProfile(params: { + currentRoles: unknown; + resultRoles: unknown; +}) { + const resultRoleById = new Map( + toRecordArray(params.resultRoles).map((role) => [toText(role.id), role]), + ); + + return toRecordArray(params.currentRoles).map((currentRole) => { + const resultRole = resultRoleById.get(toText(currentRole.id)); + if (!resultRole) { + return currentRole; + } + + return { + ...currentRole, + imageSrc: toText(resultRole.imageSrc) || null, + generatedVisualAssetId: toText(resultRole.generatedVisualAssetId) || null, + generatedAnimationSetId: + toText(resultRole.generatedAnimationSetId) || null, + animationMap: isRecord(resultRole.animationMap) + ? cloneJsonRecord(resultRole.animationMap) + : null, + } satisfies Record; + }); +} + +function syncLandmarkAssetsFromResultProfile(params: { + currentLandmarks: unknown; + resultLandmarks: unknown; +}) { + const resultLandmarkById = new Map( + toRecordArray(params.resultLandmarks).map((landmark) => [ + toText(landmark.id), + landmark, + ]), + ); + + return toRecordArray(params.currentLandmarks).map((currentLandmark) => { + const resultLandmark = resultLandmarkById.get(toText(currentLandmark.id)); + if (!resultLandmark) { + return currentLandmark; + } + + return { + ...currentLandmark, + imageSrc: toText(resultLandmark.imageSrc) || null, + } satisfies Record; + }); +} + +function syncSceneChapterAssetsFromResultProfile(params: { + currentSceneChapters: unknown; + resultSceneChapters: unknown; +}) { + const resultSceneChapterBySceneId = new Map( + toRecordArray(params.resultSceneChapters).map((chapter) => [ + toText(chapter.sceneId), + chapter, + ]), + ); + + return toRecordArray(params.currentSceneChapters).map((currentChapter) => { + const resultChapter = resultSceneChapterBySceneId.get( + toText(currentChapter.sceneId), + ); + if (!resultChapter) { + return currentChapter; + } + + const resultActById = new Map( + toRecordArray(resultChapter.acts).map((act) => [toText(act.id), act]), + ); + + return { + ...currentChapter, + acts: toRecordArray(currentChapter.acts).map((currentAct) => { + const resultAct = resultActById.get(toText(currentAct.id)); + if (!resultAct) { + return currentAct; + } + + return { + ...currentAct, + backgroundImageSrc: toText(resultAct.backgroundImageSrc) || null, + backgroundAssetId: toText(resultAct.backgroundAssetId) || null, + } satisfies Record; + }), + } satisfies Record; + }); +} + +export class CustomWorldAgentResultSyncService { + // 阶段一只允许结果页把摘要与资产确认结果回写进 foundation draft,避免 runtime 结构反向污染草稿真相源。 + syncResultProfileIntoDraftProfile(params: { + currentDraftProfile: Record | null | undefined; + resultProfile: CustomWorldProfile; + }) { + const currentDraftProfile = params.currentDraftProfile ?? {}; + const resultProfile = params.resultProfile; + + return { + ...currentDraftProfile, + name: resultProfile.name, + subtitle: resultProfile.subtitle, + summary: resultProfile.summary, + tone: resultProfile.tone, + playerGoal: resultProfile.playerGoal, + majorFactions: resultProfile.majorFactions, + coreConflicts: resultProfile.coreConflicts, + playableNpcs: syncRoleAssetsFromResultProfile({ + currentRoles: currentDraftProfile.playableNpcs, + resultRoles: resultProfile.playableNpcs, + }), + storyNpcs: syncRoleAssetsFromResultProfile({ + currentRoles: currentDraftProfile.storyNpcs, + resultRoles: resultProfile.storyNpcs, + }), + landmarks: syncLandmarkAssetsFromResultProfile({ + currentLandmarks: currentDraftProfile.landmarks, + resultLandmarks: resultProfile.landmarks, + }), + sceneChapters: syncSceneChapterAssetsFromResultProfile({ + currentSceneChapters: currentDraftProfile.sceneChapters, + resultSceneChapters: resultProfile.sceneChapterBlueprints, + }), + legacyResultProfile: resultProfile as unknown as Record, + } satisfies Record; + } +} diff --git a/server-node/src/services/customWorldAgentRoleAssetStateService.ts b/server-node/src/services/customWorldAgentRoleAssetStateService.ts index 04291936..b04e46b6 100644 --- a/server-node/src/services/customWorldAgentRoleAssetStateService.ts +++ b/server-node/src/services/customWorldAgentRoleAssetStateService.ts @@ -40,6 +40,14 @@ type DraftSceneChapterRecord = { acts: DraftSceneActRecord[]; }; +type DraftStandaloneSceneRecord = { + sceneId: string; + sceneName: string; + imageSrc: string | null; + assetId: string | null; + sceneKind: 'camp' | 'landmark'; +}; + type MergeRoleAssetIntoDraftProfilePayload = { roleId: string; portraitPath: string; @@ -244,6 +252,77 @@ function collectDraftSceneChapters(profileInput: unknown) { .filter((item): item is DraftSceneChapterRecord => Boolean(item)); } +function buildStandaloneSceneAssetSummary( + scene: DraftStandaloneSceneRecord, +): CustomWorldSceneAssetSummary { + const ready = Boolean(scene.imageSrc || scene.assetId); + + return { + sceneId: scene.sceneId, + sceneName: scene.sceneName, + actId: null, + actTitle: scene.sceneKind === 'camp' ? '营地正式背景图' : '场景正式背景图', + imageSrc: scene.imageSrc, + assetId: scene.assetId, + status: ready ? 'ready' : 'missing', + nextPointCost: ready ? 0 : 12, + }; +} + +function collectStandaloneSceneRecords( + profileInput: unknown, + coveredSceneIds: Set, +) { + const profile = toRecord(profileInput); + if (!profile) { + return [] as DraftStandaloneSceneRecord[]; + } + + const standaloneScenes: DraftStandaloneSceneRecord[] = []; + const camp = toRecord(profile.camp); + if (camp) { + const campId = toText(camp.id); + const campImageSrc = toText(camp.imageSrc) || null; + const campAssetId = toText(camp.generatedSceneAssetId) || null; + if ( + campId && + !coveredSceneIds.has(campId) && + Boolean(campImageSrc || campAssetId) + ) { + standaloneScenes.push({ + sceneId: campId, + sceneName: toText(camp.name) || '开局营地', + imageSrc: campImageSrc, + assetId: campAssetId, + sceneKind: 'camp', + }); + } + } + + toRecordArray(profile.landmarks).forEach((landmark, index) => { + const landmarkId = toText(landmark.id); + const landmarkImageSrc = toText(landmark.imageSrc) || null; + const landmarkAssetId = toText(landmark.generatedSceneAssetId) || null; + if ( + !landmarkId || + coveredSceneIds.has(landmarkId) || + !Boolean(landmarkImageSrc || landmarkAssetId) + ) { + return; + } + + standaloneScenes.push({ + sceneId: landmarkId, + sceneName: toText(landmark.name) || `关键地点 ${index + 1}`, + imageSrc: landmarkImageSrc, + assetId: landmarkAssetId, + sceneKind: 'landmark', + }); + }); + + return standaloneScenes; +} + export function resolveRoleAssetStatusLabel( status: CustomWorldRoleAssetStatus, ) { @@ -317,7 +396,7 @@ export function rebuildRoleAssetCoverage( const roleAssets = collectDraftRoles(draftProfile).map((entry) => buildRoleAssetSummary(entry), ); - const sceneAssets: CustomWorldSceneAssetSummary[] = collectDraftSceneChapters( + const chapterSceneAssets: CustomWorldSceneAssetSummary[] = collectDraftSceneChapters( draftProfile, ).flatMap((sceneChapter) => sceneChapter.acts.map((act) => { @@ -337,6 +416,14 @@ export function rebuildRoleAssetCoverage( } satisfies CustomWorldSceneAssetSummary; }), ); + const coveredSceneIds = new Set( + chapterSceneAssets.map((entry) => entry.sceneId).filter(Boolean), + ); + const standaloneSceneAssets = collectStandaloneSceneRecords( + draftProfile, + coveredSceneIds, + ).map((scene) => buildStandaloneSceneAssetSummary(scene)); + const sceneAssets = [...chapterSceneAssets, ...standaloneSceneAssets]; return { roleAssets, diff --git a/server-node/src/services/customWorldAgentSessionStore.ts b/server-node/src/services/customWorldAgentSessionStore.ts index 8ec5f599..73d5025e 100644 --- a/server-node/src/services/customWorldAgentSessionStore.ts +++ b/server-node/src/services/customWorldAgentSessionStore.ts @@ -1,510 +1,27 @@ import crypto from 'node:crypto'; import type { - CreatorIntentReadiness, CustomWorldAgentMessage, CustomWorldAgentOperationRecord, CustomWorldAgentSessionSnapshot, - CustomWorldAgentStage, - CustomWorldAssetCoverageSummary, - CustomWorldDraftCardSummary, - CustomWorldPendingClarification, - CustomWorldSuggestedAction, - EightAnchorContent, } from '../../../packages/shared/src/contracts/customWorldAgent.js'; import type { CustomWorldSessionRecord as LegacyCustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; -import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; import { - buildPendingClarifications, - evaluateCreatorIntentReadiness, - resolveCreatorIntentStage, -} from './customWorldAgentClarificationService.js'; + applyCustomWorldAgentSessionCompatibility, + isCustomWorldAgentSessionRecord, +} from './rpg-agent-session-store/rpgAgentSessionCompatibility.js'; +import { createCustomWorldAgentSessionRecord } from './rpg-agent-session-store/rpgAgentSessionFactory.js'; import { - normalizeCreatorIntentRecord, -} from './customWorldAgentIntentExtractionService.js'; -import { rebuildRoleAssetCoverage } from './customWorldAgentRoleAssetStateService.js'; + cloneRpgAgentSessionValue, + type CreateCustomWorldAgentSessionInput, + type CustomWorldAgentSessionRecord, + CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX, +} from './rpg-agent-session-store/rpgAgentSessionRecord.js'; +import type { RpgAgentSessionRepositoryPort } from '../repositories/RpgAgentSessionRepository.js'; import { - buildAnchorPackFromEightAnchorContent, - buildCreatorIntentFromEightAnchorContent, - buildDraftSummaryFromEightAnchorContent, - buildDraftTitleFromEightAnchorContent, - buildEightAnchorContentFromCreatorIntent, - createEmptyEightAnchorContent, - estimateProgressPercentFromAnchorContent, - normalizeEightAnchorContent, -} from './eightAnchorCompatibilityService.js'; - -export const CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX = - 'custom-world-agent-session-'; - -export type CustomWorldAgentSessionRecord = { - sessionId: string; - userId: string; - seedText: string; - currentTurn: number; - anchorContent: EightAnchorContent; - progressPercent: number; - lastAssistantReply: string | null; - stage: CustomWorldAgentStage; - focusCardId: string | null; - creatorIntent: Record | null; - creatorIntentReadiness: CreatorIntentReadiness; - anchorPack: Record | null; - lockState: Record | null; - draftProfile: Record | null; - messages: CustomWorldAgentMessage[]; - draftCards: CustomWorldDraftCardSummary[]; - pendingClarifications: CustomWorldPendingClarification[]; - suggestedActions: CustomWorldSuggestedAction[]; - recommendedReplies: string[]; - qualityFindings: Array<{ - id: string; - severity: 'info' | 'warning' | 'blocker'; - code: string; - targetId?: string | null; - message: string; - }>; - assetCoverage: CustomWorldAssetCoverageSummary; - operations: CustomWorldAgentOperationRecord[]; - checkpoints: Array<{ - checkpointId: string; - createdAt: string; - label: string; - }>; - createdAt: string; - updatedAt: string; -}; - -type CreateSessionInput = { - seedText?: string; - welcomeMessage: string; - currentTurn?: number; - anchorContent?: EightAnchorContent; - progressPercent?: number; - lastAssistantReply?: string | null; - pendingClarifications: CustomWorldAgentSessionRecord['pendingClarifications']; - creatorIntent?: CustomWorldAgentSessionRecord['creatorIntent']; - creatorIntentReadiness?: CreatorIntentReadiness; - anchorPack?: CustomWorldAgentSessionRecord['anchorPack']; - draftProfile?: CustomWorldAgentSessionRecord['draftProfile']; - stage?: CustomWorldAgentStage; - suggestedActions: CustomWorldSuggestedAction[]; - recommendedReplies?: string[]; -}; - -function cloneRecord(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - -function toRecord(value: unknown) { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null; -} - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function isStage(value: unknown): value is CustomWorldAgentStage { - return ( - value === 'collecting_intent' || - value === 'clarifying' || - value === 'foundation_review' || - value === 'object_refining' || - value === 'visual_refining' || - value === 'long_tail_review' || - value === 'ready_to_publish' || - value === 'published' || - value === 'error' - ); -} - -function isAgentSessionRecord( - value: unknown, -): value is CustomWorldAgentSessionRecord { - const record = toRecord(value); - if (!record) { - return false; - } - - return ( - typeof record.sessionId === 'string' && - record.sessionId.startsWith(CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX) && - typeof record.userId === 'string' && - isStage(record.stage) && - Array.isArray(record.messages) && - Array.isArray(record.operations) && - typeof record.createdAt === 'string' && - typeof record.updatedAt === 'string' - ); -} - -function isCreatorIntentReadiness( - value: unknown, -): value is CreatorIntentReadiness { - const record = toRecord(value); - if (!record) { - return false; - } - - return ( - typeof record.isReady === 'boolean' && - Array.isArray(record.completedKeys) && - Array.isArray(record.missingKeys) - ); -} - -function mapLegacyClarificationTargetKey(id: string) { - if (id === 'world_hook') return 'world_hook'; - if (id === 'player_premise') return 'player_premise'; - if (id === 'theme_and_tone' || id === 'tone_boundary') { - return 'theme_and_tone'; - } - if (id === 'core_conflict') return 'core_conflict'; - if (id === 'relationship_seed' || id === 'relationship_hook') { - return 'relationship_seed'; - } - if (id === 'iconic_element' || id === 'iconic_elements') { - return 'iconic_element'; - } - - return null; -} - -function hasUserInput(record: CustomWorldAgentSessionRecord) { - return ( - Boolean(record.seedText.trim()) || - record.messages.some( - (message) => message.role === 'user' && message.text.trim(), - ) - ); -} - -function buildCompatibleCreatorIntent(record: CustomWorldAgentSessionRecord) { - const compatibleAnchorIntent = buildCreatorIntentFromEightAnchorContent( - normalizeEightAnchorContent( - (record as Record).anchorContent ?? null, - ), - ); - - if ( - compatibleAnchorIntent && - (compatibleAnchorIntent.worldHook || - compatibleAnchorIntent.rawSettingText || - compatibleAnchorIntent.playerPremise || - compatibleAnchorIntent.openingSituation || - compatibleAnchorIntent.coreConflicts.length > 0 || - compatibleAnchorIntent.keyCharacters.length > 0 || - compatibleAnchorIntent.iconicElements.length > 0) - ) { - return compatibleAnchorIntent; - } - - return normalizeCreatorIntentRecord(record.creatorIntent); -} - -function buildCompatibleCurrentTurn(record: CustomWorldAgentSessionRecord) { - if (typeof (record as Record).currentTurn === 'number') { - return Math.max( - 0, - Math.round((record as Record).currentTurn as number), - ); - } - - return record.messages.filter((message) => message.role === 'user').length; -} - -function buildCompatibleAnchorContent(record: CustomWorldAgentSessionRecord) { - const normalized = normalizeEightAnchorContent( - (record as Record).anchorContent ?? null, - ); - - if ( - normalized.worldPromise || - normalized.playerFantasy || - normalized.themeBoundary || - normalized.playerEntryPoint || - normalized.coreConflict || - normalized.keyRelationships.length > 0 || - normalized.hiddenLines || - normalized.iconicElements - ) { - return normalized; - } - - return buildEightAnchorContentFromCreatorIntent( - buildCompatibleCreatorIntent(record), - ); -} - -function buildCompatibleProgressPercent(record: CustomWorldAgentSessionRecord) { - const rawProgress = (record as Record).progressPercent; - if (typeof rawProgress === 'number' && Number.isFinite(rawProgress)) { - return Math.max(0, Math.min(100, Math.round(rawProgress))); - } - - if ( - record.stage === 'foundation_review' || - record.stage === 'object_refining' || - record.stage === 'visual_refining' || - record.stage === 'long_tail_review' || - record.stage === 'ready_to_publish' || - record.stage === 'published' - ) { - return 100; - } - - return estimateProgressPercentFromAnchorContent( - buildCompatibleAnchorContent(record), - ); -} - -function buildCompatibleLastAssistantReply(record: CustomWorldAgentSessionRecord) { - const existingReply = (record as Record).lastAssistantReply; - if (typeof existingReply === 'string') { - return existingReply; - } - - const lastAssistantMessage = [...record.messages] - .reverse() - .find((message) => message.role === 'assistant' && message.text.trim()); - - return lastAssistantMessage?.text ?? null; -} - -function buildCompatibleReadiness(record: CustomWorldAgentSessionRecord) { - if ( - isCreatorIntentReadiness( - (record as Record).creatorIntentReadiness, - ) - ) { - return record.creatorIntentReadiness; - } - - return evaluateCreatorIntentReadiness( - normalizeCreatorIntentRecord(record.creatorIntent), - ); -} - -function buildCompatiblePendingClarifications( - record: CustomWorldAgentSessionRecord, -) { - const normalizedIntent = normalizeCreatorIntentRecord(record.creatorIntent); - const readiness = buildCompatibleReadiness(record); - const legacyClarifications = Array.isArray(record.pendingClarifications) - ? record.pendingClarifications - : []; - - const nextClarifications = legacyClarifications - .map((entry, index) => { - const targetKey = mapLegacyClarificationTargetKey(entry.id); - if (!targetKey) { - return null; - } - - return { - id: entry.id || targetKey, - label: entry.label || '待补充问题', - question: entry.question || '', - targetKey, - priority: - typeof entry.priority === 'number' ? entry.priority : index + 1, - answer: entry.answer, - } satisfies CustomWorldPendingClarification; - }) - .filter((entry): entry is CustomWorldPendingClarification => - Boolean(entry?.question), - ) - .slice(0, 3); - - if (nextClarifications.length > 0) { - return nextClarifications; - } - - return buildPendingClarifications(normalizedIntent, readiness); -} - -function buildCompatibleDraftProfile( - record: CustomWorldAgentSessionRecord, -) { - const anchorContent = buildCompatibleAnchorContent(record); - const existingDraftProfile = toRecord(record.draftProfile); - const hasFoundationContent = Boolean( - existingDraftProfile && - (typeof existingDraftProfile.name === 'string' || - Array.isArray(existingDraftProfile.playableNpcs) || - Array.isArray(existingDraftProfile.landmarks) || - Array.isArray(existingDraftProfile.factions) || - Array.isArray(existingDraftProfile.threads) || - Array.isArray(existingDraftProfile.chapters)), - ); - - if (hasFoundationContent) { - return { - ...existingDraftProfile, - name: - toText(existingDraftProfile?.name) || - toText(existingDraftProfile?.title) || - buildDraftTitleFromEightAnchorContent(anchorContent), - summary: - toText(existingDraftProfile?.summary) || - buildDraftSummaryFromEightAnchorContent(anchorContent), - }; - } - - return { - ...(existingDraftProfile ?? {}), - title: - toText(existingDraftProfile?.title) || - buildDraftTitleFromEightAnchorContent(anchorContent), - summary: - toText(existingDraftProfile?.summary) || - buildDraftSummaryFromEightAnchorContent(anchorContent), - }; -} - -function buildCompatibleSuggestedActions(params: { - record: CustomWorldAgentSessionRecord; - stage: CustomWorldAgentStage; - readiness: CreatorIntentReadiness; -}) { - if (params.record.suggestedActions.length > 0) { - // 旧快照里可能带有“精修对象”的 Agent 建议动作;当前 Agent 对话框只服务八锚点收集。 - const compatibleActions = params.record.suggestedActions.filter( - (action) => action.type !== 'refine_focus_target', - ); - if (compatibleActions.length > 0) { - return compatibleActions; - } - } - - const actions: CustomWorldSuggestedAction[] = [ - { - id: 'request_summary', - type: 'request_summary', - label: - params.stage === 'object_refining' || params.stage === 'visual_refining' - ? '总结当前世界底稿' - : '总结当前设定', - }, - ]; - if (params.stage === 'foundation_review' && params.readiness.isReady) { - actions.push({ - id: 'draft_foundation', - type: 'draft_foundation', - label: '整理一版世界底稿', - }); - return actions; - } - - return actions; -} - -function normalizeRecommendedReplies(value: unknown) { - if (!Array.isArray(value)) { - return []; - } - - return value - .map((item) => toText(item)) - .filter(Boolean) - .slice(0, 3); -} - -function buildCompatibleAssetCoverage( - record: CustomWorldAgentSessionRecord, - draftProfile: Record, -) { - const derivedCoverage = rebuildRoleAssetCoverage(draftProfile); - const existingCoverage = toRecord(record.assetCoverage); - const sceneAssets = - derivedCoverage.sceneAssets.length > 0 - ? derivedCoverage.sceneAssets - : Array.isArray(existingCoverage?.sceneAssets) - ? existingCoverage.sceneAssets - : []; - const allSceneAssetsReady = - derivedCoverage.sceneAssets.length > 0 - ? derivedCoverage.allSceneAssetsReady - : typeof existingCoverage?.allSceneAssetsReady === 'boolean' - ? existingCoverage.allSceneAssetsReady - : false; - - return { - ...derivedCoverage, - sceneAssets, - allSceneAssetsReady, - } satisfies CustomWorldAssetCoverageSummary; -} - -function applyCompatibility(record: CustomWorldAgentSessionRecord) { - const creatorIntent = buildCompatibleCreatorIntent(record); - const currentTurn = buildCompatibleCurrentTurn(record); - const anchorContent = buildCompatibleAnchorContent(record); - const progressPercent = buildCompatibleProgressPercent(record); - const lastAssistantReply = buildCompatibleLastAssistantReply(record); - const creatorIntentReadiness = - progressPercent >= 100 - ? { - isReady: true, - completedKeys: [ - 'world_hook', - 'player_premise', - 'theme_and_tone', - 'core_conflict', - 'relationship_seed', - 'iconic_element', - ], - missingKeys: [], - } - : evaluateCreatorIntentReadiness(creatorIntent); - const stage = - record.stage === 'object_refining' || - record.stage === 'visual_refining' || - record.stage === 'long_tail_review' || - record.stage === 'ready_to_publish' || - record.stage === 'published' - ? record.stage - : progressPercent >= 100 - ? ('foundation_review' as const) - : resolveCreatorIntentStage({ - hasUserInput: hasUserInput(record), - readiness: creatorIntentReadiness, - }); - const pendingClarifications = buildCompatiblePendingClarifications({ - ...record, - creatorIntent, - creatorIntentReadiness, - }); - const draftProfile = buildCompatibleDraftProfile(record); - - return { - ...record, - currentTurn, - anchorContent, - progressPercent, - lastAssistantReply, - stage, - creatorIntent, - creatorIntentReadiness, - anchorPack: - record.anchorPack && Object.keys(record.anchorPack).length > 0 - ? record.anchorPack - : buildAnchorPackFromEightAnchorContent(anchorContent, progressPercent), - draftProfile, - pendingClarifications, - suggestedActions: buildCompatibleSuggestedActions({ - record, - stage, - readiness: creatorIntentReadiness, - }), - assetCoverage: buildCompatibleAssetCoverage(record, draftProfile), - recommendedReplies: normalizeRecommendedReplies( - (record as Record).recommendedReplies, - ), - } satisfies CustomWorldAgentSessionRecord; -} + RpgAgentSessionRepositoryAdapter, +} from './rpg-agent-session-store/rpgAgentSessionRepositoryAdapter.js'; +import { normalizeEightAnchorContent } from './eightAnchorCompatibilityService.js'; function toSnapshot( record: CustomWorldAgentSessionRecord, @@ -512,132 +29,118 @@ function toSnapshot( return { sessionId: record.sessionId, currentTurn: record.currentTurn, - anchorContent: cloneRecord(record.anchorContent), + anchorContent: cloneRpgAgentSessionValue(record.anchorContent), progressPercent: record.progressPercent, lastAssistantReply: record.lastAssistantReply, stage: record.stage, focusCardId: record.focusCardId, - creatorIntent: cloneRecord(record.creatorIntent), - creatorIntentReadiness: cloneRecord(record.creatorIntentReadiness), - anchorPack: cloneRecord(record.anchorPack), - lockState: cloneRecord(record.lockState), - draftProfile: cloneRecord(record.draftProfile), - messages: cloneRecord(record.messages), - draftCards: cloneRecord(record.draftCards), - pendingClarifications: cloneRecord(record.pendingClarifications), - suggestedActions: cloneRecord(record.suggestedActions), - recommendedReplies: cloneRecord(record.recommendedReplies), - qualityFindings: cloneRecord(record.qualityFindings), - assetCoverage: cloneRecord(record.assetCoverage), + creatorIntent: cloneRpgAgentSessionValue(record.creatorIntent), + creatorIntentReadiness: cloneRpgAgentSessionValue( + record.creatorIntentReadiness, + ), + anchorPack: cloneRpgAgentSessionValue(record.anchorPack), + lockState: cloneRpgAgentSessionValue(record.lockState), + draftProfile: cloneRpgAgentSessionValue(record.draftProfile), + messages: cloneRpgAgentSessionValue(record.messages), + draftCards: cloneRpgAgentSessionValue(record.draftCards), + pendingClarifications: cloneRpgAgentSessionValue( + record.pendingClarifications, + ), + suggestedActions: cloneRpgAgentSessionValue(record.suggestedActions), + recommendedReplies: cloneRpgAgentSessionValue(record.recommendedReplies), + qualityFindings: cloneRpgAgentSessionValue(record.qualityFindings), + assetCoverage: cloneRpgAgentSessionValue(record.assetCoverage), + checkpoints: record.checkpoints.map((checkpoint) => ({ + checkpointId: checkpoint.checkpointId, + createdAt: checkpoint.createdAt, + label: checkpoint.label, + })), updatedAt: record.updatedAt, }; } -export class CustomWorldAgentSessionStore { - constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {} +function normalizeCompatibleSessionRecord( + record: CustomWorldAgentSessionRecord, +): CustomWorldAgentSessionRecord { + return cloneRpgAgentSessionValue( + applyCustomWorldAgentSessionCompatibility( + record, + ) as unknown as CustomWorldAgentSessionRecord, + ); +} - private async persist(record: CustomWorldAgentSessionRecord) { - await this.runtimeRepository.upsertCustomWorldSession( +export { CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX }; +export type { CustomWorldAgentSessionRecord }; + +export class CustomWorldAgentSessionStore { + private readonly sessionRepository: RpgAgentSessionRepositoryAdapter; + + constructor(sessionRepository: RpgAgentSessionRepositoryPort) { + this.sessionRepository = new RpgAgentSessionRepositoryAdapter( + sessionRepository, + ); + } + + private async persist( + record: CustomWorldAgentSessionRecord, + ): Promise { + await this.sessionRepository.upsert( record.userId, record.sessionId, record as unknown as LegacyCustomWorldSessionRecord, ); - return cloneRecord(record); + + return cloneRpgAgentSessionValue(record); } private async mutate( userId: string, sessionId: string, mutateFn: (record: CustomWorldAgentSessionRecord) => void, - ) { + ): Promise { const current = await this.get(userId, sessionId); if (!current) { return null; } - const nextRecord = cloneRecord(current); + const nextRecord = cloneRpgAgentSessionValue(current); mutateFn(nextRecord); nextRecord.updatedAt = new Date().toISOString(); return this.persist(nextRecord); } - async create(userId: string, input: CreateSessionInput) { - const sessionId = `${CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX}${crypto.randomBytes(16).toString('hex')}`; - const now = new Date().toISOString(); - const welcomeMessage: CustomWorldAgentMessage = { - id: `message-${crypto.randomBytes(8).toString('hex')}`, - role: 'assistant', - kind: 'chat', - text: input.welcomeMessage, - createdAt: now, - relatedOperationId: null, - }; - const record: CustomWorldAgentSessionRecord = { - sessionId, - userId, - seedText: input.seedText?.trim() ?? '', - currentTurn: Math.max(0, Math.round(input.currentTurn ?? 0)), - anchorContent: normalizeEightAnchorContent( - input.anchorContent ?? createEmptyEightAnchorContent(), - ), - progressPercent: Math.max( - 0, - Math.min(100, Math.round(input.progressPercent ?? 0)), - ), - lastAssistantReply: input.lastAssistantReply ?? input.welcomeMessage, - stage: input.stage ?? 'collecting_intent', - focusCardId: null, - creatorIntent: cloneRecord(input.creatorIntent ?? {}), - creatorIntentReadiness: input.creatorIntentReadiness ?? { - isReady: false, - completedKeys: [], - missingKeys: [], - }, - anchorPack: cloneRecord(input.anchorPack ?? {}), - lockState: {}, - draftProfile: cloneRecord(input.draftProfile ?? {}), - messages: [welcomeMessage], - draftCards: [], - pendingClarifications: cloneRecord(input.pendingClarifications), - suggestedActions: cloneRecord(input.suggestedActions), - recommendedReplies: cloneRecord(input.recommendedReplies ?? []), - qualityFindings: [], - assetCoverage: rebuildRoleAssetCoverage(input.draftProfile ?? {}), - operations: [], - checkpoints: [], - createdAt: now, - updatedAt: now, - }; - - const compatibleRecord = applyCompatibility(record); - await this.persist(compatibleRecord); - return cloneRecord(compatibleRecord); + async create( + userId: string, + input: CreateCustomWorldAgentSessionInput, + ): Promise { + const record = createCustomWorldAgentSessionRecord(userId, input); + await this.persist(record); + return cloneRpgAgentSessionValue(record); } - async list(userId: string) { - const records = - await this.runtimeRepository.listCustomWorldSessions(userId); + async list(userId: string): Promise { + const records = await this.sessionRepository.list(userId); return records - .filter((record) => isAgentSessionRecord(record)) - .map((record) => cloneRecord(applyCompatibility(record))) + .filter((record) => isCustomWorldAgentSessionRecord(record)) + .map((record) => normalizeCompatibleSessionRecord(record)) .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); } - async get(userId: string, sessionId: string) { + async get( + userId: string, + sessionId: string, + ): Promise { if (!sessionId.trim()) { return null; } - const record = await this.runtimeRepository.getCustomWorldSession( - userId, - sessionId, - ); - if (!isAgentSessionRecord(record)) { + const record = await this.sessionRepository.get(userId, sessionId); + if (!isCustomWorldAgentSessionRecord(record)) { return null; } - return cloneRecord(applyCompatibility(record)); + return normalizeCompatibleSessionRecord(record); } async getSnapshot(userId: string, sessionId: string) { @@ -651,7 +154,7 @@ export class CustomWorldAgentSessionStore { message: CustomWorldAgentMessage, ) { return this.mutate(userId, sessionId, (record) => { - record.messages.push(cloneRecord(message)); + record.messages.push(cloneRpgAgentSessionValue(message)); }); } @@ -704,39 +207,47 @@ export class CustomWorldAgentSessionStore { record.focusCardId = patch.focusCardId; } if (patch.creatorIntent !== undefined) { - record.creatorIntent = cloneRecord(patch.creatorIntent); + record.creatorIntent = cloneRpgAgentSessionValue(patch.creatorIntent); } if (patch.creatorIntentReadiness !== undefined) { - record.creatorIntentReadiness = cloneRecord( + record.creatorIntentReadiness = cloneRpgAgentSessionValue( patch.creatorIntentReadiness, ); } if (patch.anchorPack !== undefined) { - record.anchorPack = cloneRecord(patch.anchorPack); + record.anchorPack = cloneRpgAgentSessionValue(patch.anchorPack); } if (patch.lockState !== undefined) { - record.lockState = cloneRecord(patch.lockState); + record.lockState = cloneRpgAgentSessionValue(patch.lockState); } if (patch.draftProfile !== undefined) { - record.draftProfile = cloneRecord(patch.draftProfile); + record.draftProfile = cloneRpgAgentSessionValue(patch.draftProfile); } if (patch.pendingClarifications !== undefined) { - record.pendingClarifications = cloneRecord(patch.pendingClarifications); + record.pendingClarifications = cloneRpgAgentSessionValue( + patch.pendingClarifications, + ); } if (patch.suggestedActions !== undefined) { - record.suggestedActions = cloneRecord(patch.suggestedActions); + record.suggestedActions = cloneRpgAgentSessionValue( + patch.suggestedActions, + ); } if (patch.recommendedReplies !== undefined) { - record.recommendedReplies = cloneRecord(patch.recommendedReplies); + record.recommendedReplies = cloneRpgAgentSessionValue( + patch.recommendedReplies, + ); } if (patch.draftCards !== undefined) { - record.draftCards = cloneRecord(patch.draftCards); + record.draftCards = cloneRpgAgentSessionValue(patch.draftCards); } if (patch.qualityFindings !== undefined) { - record.qualityFindings = cloneRecord(patch.qualityFindings); + record.qualityFindings = cloneRpgAgentSessionValue( + patch.qualityFindings, + ); } if (patch.assetCoverage !== undefined) { - record.assetCoverage = cloneRecord(patch.assetCoverage); + record.assetCoverage = cloneRpgAgentSessionValue(patch.assetCoverage); } }); } @@ -747,7 +258,7 @@ export class CustomWorldAgentSessionStore { operation: CustomWorldAgentOperationRecord, ) { return this.mutate(userId, sessionId, (record) => { - record.operations.push(cloneRecord(operation)); + record.operations.push(cloneRpgAgentSessionValue(operation)); }); } @@ -760,7 +271,7 @@ export class CustomWorldAgentSessionStore { const operation = record.operations.find( (item) => item.operationId === operationId, ); - return operation ? cloneRecord(operation) : null; + return operation ? cloneRpgAgentSessionValue(operation) : null; } async updateOperation( @@ -804,6 +315,28 @@ export class CustomWorldAgentSessionStore { input: { checkpointId?: string; label: string; + snapshot?: Partial< + Pick< + CustomWorldAgentSessionRecord, + | 'currentTurn' + | 'anchorContent' + | 'progressPercent' + | 'lastAssistantReply' + | 'stage' + | 'focusCardId' + | 'creatorIntent' + | 'creatorIntentReadiness' + | 'anchorPack' + | 'lockState' + | 'draftProfile' + | 'pendingClarifications' + | 'suggestedActions' + | 'recommendedReplies' + | 'draftCards' + | 'qualityFindings' + | 'assetCoverage' + > + > | null; }, ) { return this.mutate(userId, sessionId, (record) => { @@ -813,12 +346,106 @@ export class CustomWorldAgentSessionStore { `checkpoint-${crypto.randomBytes(8).toString('hex')}`, createdAt: new Date().toISOString(), label: input.label, + snapshot: input.snapshot + ? { + currentTurn: + typeof input.snapshot.currentTurn === 'number' + ? Math.max(0, Math.round(input.snapshot.currentTurn)) + : record.currentTurn, + anchorContent: cloneRpgAgentSessionValue( + input.snapshot.anchorContent ?? record.anchorContent, + ), + progressPercent: + typeof input.snapshot.progressPercent === 'number' + ? Math.max( + 0, + Math.min(100, Math.round(input.snapshot.progressPercent)), + ) + : record.progressPercent, + lastAssistantReply: + input.snapshot.lastAssistantReply ?? record.lastAssistantReply, + stage: input.snapshot.stage ?? record.stage, + focusCardId: + input.snapshot.focusCardId !== undefined + ? input.snapshot.focusCardId + : record.focusCardId, + creatorIntent: cloneRpgAgentSessionValue( + input.snapshot.creatorIntent ?? record.creatorIntent, + ), + creatorIntentReadiness: cloneRpgAgentSessionValue( + input.snapshot.creatorIntentReadiness ?? + record.creatorIntentReadiness, + ), + anchorPack: cloneRpgAgentSessionValue( + input.snapshot.anchorPack ?? record.anchorPack, + ), + lockState: cloneRpgAgentSessionValue( + input.snapshot.lockState ?? record.lockState, + ), + draftProfile: cloneRpgAgentSessionValue( + input.snapshot.draftProfile ?? record.draftProfile, + ), + pendingClarifications: cloneRpgAgentSessionValue( + input.snapshot.pendingClarifications ?? + record.pendingClarifications, + ), + suggestedActions: cloneRpgAgentSessionValue( + input.snapshot.suggestedActions ?? record.suggestedActions, + ), + recommendedReplies: cloneRpgAgentSessionValue( + input.snapshot.recommendedReplies ?? record.recommendedReplies, + ), + draftCards: cloneRpgAgentSessionValue( + input.snapshot.draftCards ?? record.draftCards, + ), + qualityFindings: cloneRpgAgentSessionValue( + input.snapshot.qualityFindings ?? record.qualityFindings, + ), + assetCoverage: cloneRpgAgentSessionValue( + input.snapshot.assetCoverage ?? record.assetCoverage, + ), + } + : null, }); }); } + async restoreCheckpoint( + userId: string, + sessionId: string, + checkpointId: string, + ) { + return this.mutate(userId, sessionId, (record) => { + const checkpoint = record.checkpoints.find( + (entry) => entry.checkpointId === checkpointId, + ); + if (!checkpoint?.snapshot) { + return; + } + + const snapshot = cloneRpgAgentSessionValue(checkpoint.snapshot); + record.currentTurn = snapshot.currentTurn; + record.anchorContent = snapshot.anchorContent; + record.progressPercent = snapshot.progressPercent; + record.lastAssistantReply = snapshot.lastAssistantReply; + record.stage = snapshot.stage; + record.focusCardId = snapshot.focusCardId; + record.creatorIntent = snapshot.creatorIntent; + record.creatorIntentReadiness = snapshot.creatorIntentReadiness; + record.anchorPack = snapshot.anchorPack; + record.lockState = snapshot.lockState; + record.draftProfile = snapshot.draftProfile; + record.pendingClarifications = snapshot.pendingClarifications; + record.suggestedActions = snapshot.suggestedActions; + record.recommendedReplies = snapshot.recommendedReplies; + record.draftCards = snapshot.draftCards; + record.qualityFindings = snapshot.qualityFindings; + record.assetCoverage = snapshot.assetCoverage; + }); + } + async listDraftCards(userId: string, sessionId: string) { const record = await this.get(userId, sessionId); - return record ? cloneRecord(record.draftCards) : null; + return record ? cloneRpgAgentSessionValue(record.draftCards) : null; } } diff --git a/server-node/src/services/customWorldAgentSnapshotBuilder.ts b/server-node/src/services/customWorldAgentSnapshotBuilder.ts new file mode 100644 index 00000000..76f69d3e --- /dev/null +++ b/server-node/src/services/customWorldAgentSnapshotBuilder.ts @@ -0,0 +1,199 @@ +import type { + CreatorIntentReadiness, + CustomWorldAgentStage, + CustomWorldAssetCoverageSummary, + CustomWorldDraftCardSummary, + CustomWorldPendingClarification, + EightAnchorContent, +} from '../../../packages/shared/src/contracts/customWorldAgent.js'; +import { + buildDraftSummaryFromIntent, + buildDraftTitleFromIntent, + type CustomWorldCreatorIntentRecord, +} from './customWorldAgentIntentExtractionService.js'; +import { CustomWorldAgentQualityGateService } from './customWorldAgentQualityGateService.js'; +import { rebuildRoleAssetCoverage } from './customWorldAgentRoleAssetStateService.js'; +import type { CustomWorldAgentSessionRecord } from './customWorldAgentSessionStore.js'; +import { CustomWorldAgentSuggestedActionService } from './customWorldAgentSuggestedActionService.js'; +import { buildAnchorPackFromEightAnchorContent } from './eightAnchorCompatibilityService.js'; +import { CustomWorldAgentDraftCompiler } from './customWorldAgentDraftCompiler.js'; + +export type CustomWorldAgentDerivedStatePatch = Partial< + Pick< + CustomWorldAgentSessionRecord, + | 'currentTurn' + | 'anchorContent' + | 'progressPercent' + | 'lastAssistantReply' + | 'stage' + | 'focusCardId' + | 'creatorIntent' + | 'creatorIntentReadiness' + | 'anchorPack' + | 'draftProfile' + | 'pendingClarifications' + | 'suggestedActions' + | 'recommendedReplies' + | 'draftCards' + | 'qualityFindings' + | 'assetCoverage' + > +>; + +export class CustomWorldAgentSnapshotBuilder { + constructor( + private readonly draftCompiler: CustomWorldAgentDraftCompiler, + private readonly suggestedActionService: CustomWorldAgentSuggestedActionService, + private readonly qualityGateService: CustomWorldAgentQualityGateService, + ) {} + + // 把“草稿改动后需要重算哪些派生字段”统一封装成一个入口,避免每个 action 都重复拼 patch。 + buildRefiningState(params: { + previousStage: CustomWorldAgentStage; + draftProfile: Record; + draftCards?: CustomWorldDraftCardSummary[]; + assetCoverage?: CustomWorldAssetCoverageSummary; + nextStage?: Extract< + CustomWorldAgentStage, + | 'object_refining' + | 'visual_refining' + | 'long_tail_review' + | 'ready_to_publish' + >; + focusCardId?: string | null; + }): CustomWorldAgentDerivedStatePatch { + const nextDraftCards = + params.draftCards ?? this.draftCompiler.compileDraftCards(params.draftProfile); + const assetCoverage = + params.assetCoverage ?? rebuildRoleAssetCoverage(params.draftProfile); + const nextStage = + params.nextStage ?? + (params.previousStage === 'visual_refining' + ? 'visual_refining' + : params.previousStage === 'long_tail_review' + ? 'long_tail_review' + : params.previousStage === 'ready_to_publish' + ? 'ready_to_publish' + : 'object_refining'); + + return { + stage: nextStage, + draftProfile: params.draftProfile, + draftCards: nextDraftCards, + assetCoverage, + qualityFindings: this.qualityGateService.buildQualityFindings({ + draftProfile: params.draftProfile, + assetCoverage, + stage: nextStage, + }), + focusCardId: params.focusCardId, + suggestedActions: this.suggestedActionService.buildSuggestedActions({ + stage: nextStage, + isReady: true, + draftProfile: params.draftProfile, + draftCards: nextDraftCards, + }), + recommendedReplies: [], + }; + } + + buildFoundationDraftState(params: { + creatorIntent: Record | null; + anchorPack: Record | null; + draftProfile: Record; + assetCoverage?: CustomWorldAssetCoverageSummary; + }): CustomWorldAgentDerivedStatePatch { + return { + ...this.buildRefiningState({ + previousStage: 'object_refining', + nextStage: 'object_refining', + draftProfile: params.draftProfile, + assetCoverage: params.assetCoverage, + }), + creatorIntent: params.creatorIntent, + anchorPack: params.anchorPack, + pendingClarifications: [], + }; + } + + buildMessageTurnState(params: { + latestSession: CustomWorldAgentSessionRecord; + nextAnchorContent: EightAnchorContent; + progressPercent: number; + replyText: string; + nextCreatorIntent: CustomWorldCreatorIntentRecord; + creatorIntentReadiness: CreatorIntentReadiness; + derivedDraftProfile: { + title: string; + summary: string; + }; + derivedPendingClarifications: CustomWorldPendingClarification[]; + derivedStage: CustomWorldAgentStage; + shouldStayInDraftStage: boolean; + }): CustomWorldAgentDerivedStatePatch { + const preservedStage = + params.latestSession.stage === 'visual_refining' + ? ('visual_refining' as const) + : ('object_refining' as const); + const nextDraftProfile = params.shouldStayInDraftStage + ? ((params.latestSession.draftProfile ?? {}) as Record) + : params.progressPercent >= 100 + ? { + title: buildDraftTitleFromIntent(params.nextCreatorIntent), + summary: buildDraftSummaryFromIntent(params.nextCreatorIntent), + } + : params.derivedDraftProfile; + const nextStage = params.shouldStayInDraftStage + ? preservedStage + : params.derivedStage; + const assetCoverage = params.shouldStayInDraftStage + ? params.latestSession.assetCoverage + : rebuildRoleAssetCoverage(nextDraftProfile); + + return { + currentTurn: params.latestSession.currentTurn + 1, + anchorContent: params.nextAnchorContent, + progressPercent: params.progressPercent, + lastAssistantReply: params.replyText, + stage: nextStage, + focusCardId: params.shouldStayInDraftStage + ? params.latestSession.focusCardId + : null, + creatorIntent: params.nextCreatorIntent, + creatorIntentReadiness: params.creatorIntentReadiness, + anchorPack: buildAnchorPackFromEightAnchorContent( + params.nextAnchorContent, + params.progressPercent, + ), + draftProfile: nextDraftProfile, + draftCards: params.shouldStayInDraftStage + ? params.latestSession.draftCards + : [], + assetCoverage, + qualityFindings: this.qualityGateService.buildQualityFindings({ + draftProfile: nextDraftProfile, + assetCoverage, + stage: nextStage, + }), + pendingClarifications: + params.progressPercent >= 100 ? [] : params.derivedPendingClarifications, + suggestedActions: params.shouldStayInDraftStage + ? this.suggestedActionService.buildSuggestedActions({ + stage: preservedStage, + isReady: true, + draftProfile: params.latestSession.draftProfile, + draftCards: params.latestSession.draftCards, + }) + : params.progressPercent >= 100 + ? [ + { + id: 'draft_foundation', + type: 'draft_foundation', + label: '生成游戏设定草稿', + }, + ] + : [], + recommendedReplies: [], + }; + } +} diff --git a/server-node/src/services/customWorldAgentSuggestedActionService.ts b/server-node/src/services/customWorldAgentSuggestedActionService.ts new file mode 100644 index 00000000..3516224a --- /dev/null +++ b/server-node/src/services/customWorldAgentSuggestedActionService.ts @@ -0,0 +1,82 @@ +import type { + CustomWorldAgentStage, + CustomWorldDraftCardSummary, + CustomWorldSuggestedAction, +} from '../../../packages/shared/src/contracts/customWorldAgent.js'; +import { + getWorldFoundationCardId, + normalizeFoundationDraftProfile, +} from './customWorldAgentDraftCompiler.js'; + +export class CustomWorldAgentSuggestedActionService { + // 统一维护 Agent 草稿阶段的建议动作,避免继续散落在 orchestrator 和 store 的兼容逻辑里。 + buildSuggestedActions( + params: { + stage?: CustomWorldAgentStage; + isReady?: boolean; + draftProfile?: unknown; + draftCards?: CustomWorldDraftCardSummary[]; + } = {}, + ): CustomWorldSuggestedAction[] { + const profile = normalizeFoundationDraftProfile(params.draftProfile); + const actions: CustomWorldSuggestedAction[] = [ + { + id: 'request_summary', + type: 'request_summary', + label: + params.stage === 'object_refining' || + params.stage === 'visual_refining' + ? '总结当前世界底稿' + : '总结当前设定', + }, + ]; + + if (params.stage === 'foundation_review' && params.isReady) { + actions.push({ + id: 'draft_foundation', + type: 'draft_foundation', + label: '整理一版世界底稿', + }); + return actions; + } + + if ( + (params.stage === 'object_refining' || + params.stage === 'visual_refining') && + profile + ) { + const worldCardId = + params.draftCards?.find((entry) => entry.kind === 'world')?.id ?? + getWorldFoundationCardId(); + const firstCharacter = [...profile.playableNpcs, ...profile.storyNpcs][0]; + const firstLandmark = profile.landmarks[0]; + + actions.push({ + id: 'refine_world', + type: 'refine_focus_target', + label: '先看世界总卡', + targetId: worldCardId, + }); + + if (firstCharacter) { + actions.push({ + id: `refine-character-${firstCharacter.id}`, + type: 'refine_focus_target', + label: `精修角色:${firstCharacter.name}`, + targetId: firstCharacter.id, + }); + } + + if (firstLandmark) { + actions.push({ + id: `refine-landmark-${firstLandmark.id}`, + type: 'refine_focus_target', + label: `继续补地点:${firstLandmark.name}`, + targetId: firstLandmark.id, + }); + } + } + + return actions; + } +} diff --git a/server-node/src/services/customWorldWorkSummaryService.integration.test.ts b/server-node/src/services/customWorldWorkSummaryService.integration.test.ts new file mode 100644 index 00000000..9dde95cb --- /dev/null +++ b/server-node/src/services/customWorldWorkSummaryService.integration.test.ts @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createRpgAgentSessionFixture, + createRpgCreationWorksResponseFixture, + createRpgWorldLibraryEntryFixture, +} 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'; + +test('work summary service can aggregate shared RPG fixtures into draft and published entries', async () => { + const sessionFixture = createRpgAgentSessionFixture(); + const sessionRecord: CustomWorldSessionRecord = { + ...JSON.parse(JSON.stringify(sessionFixture)), + sessionId: `${CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX}fixture`, + userId: 'fixture-user', + seedText: '被海雾吞没的旧航路群岛', + operations: [], + checkpoints: [], + createdAt: sessionFixture.updatedAt, + updatedAt: sessionFixture.updatedAt, + } as CustomWorldSessionRecord; + const { rpgAgentSessionRepository, rpgWorldProfileRepository } = + createInMemoryRpgWorldRepositoryPorts({ + sessionRecords: [sessionRecord], + profileEntries: [createRpgWorldLibraryEntryFixture()], + }); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); + const summaries = await listCustomWorldWorkSummaries('fixture-user', { + rpgWorldProfiles: rpgWorldProfileRepository, + customWorldAgentSessions: sessionStore, + }); + const expected = createRpgCreationWorksResponseFixture(); + + assert.equal(summaries.length, expected.items.length); + + const draftItem = summaries.find((entry) => entry.sourceType === 'agent_session'); + const publishedItem = summaries.find( + (entry) => entry.sourceType === 'published_profile', + ); + const expectedDraft = expected.items.find( + (entry) => entry.sourceType === 'agent_session', + ); + const expectedPublished = expected.items.find( + (entry) => entry.sourceType === 'published_profile', + ); + + assert.ok(draftItem); + assert.ok(publishedItem); + assert.ok(expectedDraft); + assert.ok(expectedPublished); + + assert.equal(draftItem?.title, expectedDraft?.title); + assert.equal(draftItem?.subtitle, expectedDraft?.subtitle); + assert.equal(draftItem?.coverRenderMode, expectedDraft?.coverRenderMode); + assert.deepEqual( + draftItem?.coverCharacterImageSrcs, + expectedDraft?.coverCharacterImageSrcs, + ); + assert.equal(draftItem?.roleAssetSummaryLabel, expectedDraft?.roleAssetSummaryLabel); + assert.equal(draftItem?.publishReady, expectedDraft?.publishReady); + assert.equal(draftItem?.blockerCount, expectedDraft?.blockerCount); + + assert.equal(publishedItem?.title, expectedPublished?.title); + assert.equal(publishedItem?.profileId, expectedPublished?.profileId); + assert.equal(publishedItem?.canEnterWorld, true); + assert.equal(publishedItem?.coverRenderMode, expectedPublished?.coverRenderMode); +}); + +test('published agent sessions are filtered out after works unify to published profile truth', async () => { + const sessionFixture = createRpgAgentSessionFixture(); + const sessionRecord: CustomWorldSessionRecord = { + ...JSON.parse(JSON.stringify(sessionFixture)), + sessionId: `${CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX}published-fixture`, + userId: 'fixture-user', + seedText: '被海雾吞没的旧航路群岛', + stage: 'published', + operations: [], + checkpoints: [], + createdAt: sessionFixture.updatedAt, + updatedAt: sessionFixture.updatedAt, + } as CustomWorldSessionRecord; + const { rpgAgentSessionRepository, rpgWorldProfileRepository } = + createInMemoryRpgWorldRepositoryPorts({ + sessionRecords: [sessionRecord], + profileEntries: [createRpgWorldLibraryEntryFixture()], + }); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); + + const summaries = await listCustomWorldWorkSummaries('fixture-user', { + rpgWorldProfiles: rpgWorldProfileRepository, + customWorldAgentSessions: sessionStore, + }); + + assert.equal( + summaries.some((entry) => entry.sourceType === 'agent_session'), + false, + ); + assert.equal( + summaries.filter((entry) => entry.sourceType === 'published_profile').length, + 1, + ); +}); diff --git a/server-node/src/services/customWorldWorkSummaryService.ts b/server-node/src/services/customWorldWorkSummaryService.ts index 9111b4b9..f66fef77 100644 --- a/server-node/src/services/customWorldWorkSummaryService.ts +++ b/server-node/src/services/customWorldWorkSummaryService.ts @@ -1,301 +1,20 @@ -import type { - CustomWorldAgentStage, - CustomWorldWorkSummary, -} from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import type { - CustomWorldLibraryEntry, - CustomWorldProfileRecord, -} from '../../../packages/shared/src/contracts/runtime.js'; -import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; -import { resolveCustomWorldCoverPresentation } from '../repositories/customWorldLibraryMetadata.js'; -import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; -import { - buildDraftSummaryFromIntent, - buildDraftTitleFromIntent, - normalizeCreatorIntentRecord, -} from './customWorldAgentIntentExtractionService.js'; -import { - rebuildRoleAssetCoverage, - resolveRoleAssetStatusLabel, -} from './customWorldAgentRoleAssetStateService.js'; -import type { - CustomWorldAgentSessionRecord, - CustomWorldAgentSessionStore, -} from './customWorldAgentSessionStore.js'; -import { - buildDraftSummaryFromEightAnchorContent, - buildDraftTitleFromEightAnchorContent, -} from './eightAnchorCompatibilityService.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) - : null; -} - -function toRecordArray(value: unknown) { - return Array.isArray(value) - ? value.filter((item) => item && typeof item === 'object') - : []; -} - -function truncateText(value: string, maxLength: number) { - if (value.length <= maxLength) { - return value; - } - - return `${value.slice(0, Math.max(0, maxLength - 1)).trim()}…`; -} - -function formatDraftStageLabel(stage: CustomWorldAgentStage) { - if (stage === 'collecting_intent') return '收集世界锚点'; - if (stage === 'clarifying') return '补齐关键锚点'; - if (stage === 'foundation_review') return '准备整理底稿'; - if (stage === 'object_refining') return '待完善草稿'; - if (stage === 'visual_refining') return '视觉工坊'; - if (stage === 'long_tail_review') return '扩展长尾'; - if (stage === 'ready_to_publish') return '准备发布'; - if (stage === 'published') return '已发布'; - return '发生错误'; -} - -function resolveDraftTitle(session: CustomWorldAgentSessionRecord) { - const intent = normalizeCreatorIntentRecord(session.creatorIntent); - const draftProfile = normalizeFoundationDraftProfile(session.draftProfile); - - return ( - draftProfile?.name || - buildDraftTitleFromEightAnchorContent(session.anchorContent) || - buildDraftTitleFromIntent(intent) || - toText(session.draftProfile?.title) || - truncateText(session.seedText, 18) || - '未命名草稿' - ); -} - -function resolveDraftSummary(session: CustomWorldAgentSessionRecord) { - const intent = normalizeCreatorIntentRecord(session.creatorIntent); - const compiledSummary = buildDraftSummaryFromIntent(intent); - const draftProfile = normalizeFoundationDraftProfile(session.draftProfile); - - return ( - draftProfile?.summary || - buildDraftSummaryFromEightAnchorContent(session.anchorContent) || - compiledSummary || - toText(session.draftProfile?.summary) || - truncateText(session.seedText, 72) || - '还在收集你的世界锚点。' - ); -} - -function resolveDraftCounts(session: CustomWorldAgentSessionRecord) { - const draftProfile = normalizeFoundationDraftProfile(session.draftProfile); - if (draftProfile) { - // 草稿列表里的“角色”展示的是当前草稿中全部可编辑角色,而不是仅限可扮演角色。 - const totalRoleCount = [ - ...new Set( - [...draftProfile.playableNpcs, ...draftProfile.storyNpcs].map( - (entry) => entry.id, - ), - ), - ].length; - - return { - playableNpcCount: totalRoleCount, - landmarkCount: draftProfile.landmarks.length, - }; - } - - const playableNpcCount = session.draftCards.filter( - (card) => card.kind === 'character', - ).length; - const landmarkCount = session.draftCards.filter( - (card) => card.kind === 'landmark' || card.kind === 'camp', - ).length; - - return { - playableNpcCount, - landmarkCount, - }; -} - -function resolveDraftRoleAssetProgress(session: CustomWorldAgentSessionRecord) { - const coverage = rebuildRoleAssetCoverage(session.draftProfile); - const roleVisualReadyCount = coverage.roleAssets.filter( - (entry) => entry.status !== 'missing', - ).length; - const roleAnimationReadyCount = coverage.roleAssets.filter( - (entry) => entry.status === 'complete', - ).length; - const leadRole = coverage.roleAssets[0]; - - return { - roleVisualReadyCount, - roleAnimationReadyCount, - roleAssetSummaryLabel: leadRole - ? `${leadRole.roleName} · ${resolveRoleAssetStatusLabel(leadRole.status)}` - : coverage.roleAssets.length > 0 - ? '角色资产进行中' - : null, - }; -} - -function resolveDraftCover(session: CustomWorldAgentSessionRecord) { - const draftProfile = toRecord(session.draftProfile); - if (!draftProfile) { - return { - imageSrc: null, - renderMode: 'image' as const, - characterImageSrcs: [], - }; - } - - return resolveCustomWorldCoverPresentation( - draftProfile as CustomWorldProfileRecord, - ); -} - -function isLibraryEntry( - value: unknown, -): value is CustomWorldLibraryEntry { - const record = toRecord(value); - return ( - Boolean(record) && - typeof record.ownerUserId === 'string' && - typeof record.profileId === 'string' && - Boolean(toRecord(record.profile)) - ); -} - -function isPublishedLibraryEntry( - value: unknown, -): value is CustomWorldLibraryEntry { - return isLibraryEntry(value) && value.visibility === 'published'; -} +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: { - runtimeRepository: RuntimeRepositoryPort; + rpgWorldProfiles: RpgWorldProfileRepositoryPort; customWorldAgentSessions: CustomWorldAgentSessionStore; }, ) { - const [profiles, sessions] = await Promise.all([ - dependencies.runtimeRepository.listCustomWorldProfiles(userId), - dependencies.customWorldAgentSessions.list(userId), - ]); - - const draftItems: CustomWorldWorkSummary[] = sessions.map((session) => { - const counts = resolveDraftCounts(session); - const roleAssetProgress = resolveDraftRoleAssetProgress(session); - const coverPresentation = resolveDraftCover(session); - - return { - workId: `draft:${session.sessionId}`, - sourceType: 'agent_session', - status: 'draft', - title: resolveDraftTitle(session), - subtitle: - normalizeFoundationDraftProfile(session.draftProfile)?.subtitle || - formatDraftStageLabel(session.stage), - summary: resolveDraftSummary(session), - coverImageSrc: coverPresentation.imageSrc, - coverRenderMode: coverPresentation.renderMode, - coverCharacterImageSrcs: coverPresentation.characterImageSrcs, - updatedAt: session.updatedAt, - publishedAt: null, - stage: session.stage, - stageLabel: formatDraftStageLabel(session.stage), - playableNpcCount: counts.playableNpcCount, - landmarkCount: counts.landmarkCount, - roleVisualReadyCount: roleAssetProgress.roleVisualReadyCount, - roleAnimationReadyCount: roleAssetProgress.roleAnimationReadyCount, - roleAssetSummaryLabel: roleAssetProgress.roleAssetSummaryLabel, - sessionId: session.sessionId, - profileId: null, - canResume: true, - canEnterWorld: false, - }; - }); - - const publishedItems: CustomWorldWorkSummary[] = profiles - .filter((profile) => isPublishedLibraryEntry(profile)) - .map((profile) => { - const libraryEntry = profile; - const profileRecord = ( - libraryEntry?.profile ?? profile - ) as CustomWorldProfileRecord & Record; - const playableNpcs = toRecordArray(profileRecord.playableNpcs); - const landmarks = toRecordArray(profileRecord.landmarks); - const updatedAt = - (libraryEntry ? toText(libraryEntry.updatedAt) : '') || - toText(profileRecord.updatedAt) || - new Date().toISOString(); - const coverPresentation = resolveCustomWorldCoverPresentation(profileRecord); - const roleVisualReadyCount = playableNpcs.filter( - (entry) => - Boolean(toText(entry.imageSrc)) && - Boolean(toText(entry.generatedVisualAssetId)), - ).length; - const roleAnimationReadyCount = playableNpcs.filter( - (entry) => Boolean(toText(entry.generatedAnimationSetId)), - ).length; - - return { - workId: `published:${toText(profileRecord.id) || updatedAt}`, - sourceType: 'published_profile', - status: 'published', - title: - toText(libraryEntry.worldName) || - toText(profileRecord.name) || - '未命名世界', - subtitle: - toText(libraryEntry.subtitle) || - toText(profileRecord.subtitle) || - '已保存作品', - summary: - toText(libraryEntry.summaryText) || - toText(profileRecord.summary) || - '这个世界已经可以直接进入体验。', - coverImageSrc: libraryEntry.coverImageSrc || coverPresentation.imageSrc, - coverRenderMode: coverPresentation.renderMode, - coverCharacterImageSrcs: coverPresentation.characterImageSrcs, - updatedAt, - publishedAt: - toText(libraryEntry.publishedAt) || - toText(profileRecord.publishedAt) || - updatedAt, - stage: 'published', - stageLabel: '已发布', - playableNpcCount: - libraryEntry.playableNpcCount > 0 - ? libraryEntry.playableNpcCount - : playableNpcs.length, - landmarkCount: - libraryEntry.landmarkCount > 0 - ? libraryEntry.landmarkCount - : landmarks.length, - roleVisualReadyCount, - roleAnimationReadyCount, - roleAssetSummaryLabel: - roleAnimationReadyCount > 0 - ? `动作已就绪 ${roleAnimationReadyCount}` - : roleVisualReadyCount > 0 - ? `主图已就绪 ${roleVisualReadyCount}` - : null, - sessionId: null, - profileId: - toText(libraryEntry.profileId) || toText(profileRecord.id) || null, - canResume: false, - canEnterWorld: true, - }; - }); - - return [...draftItems, ...publishedItems].sort((left, right) => - right.updatedAt.localeCompare(left.updatedAt), + const service = new RpgWorldWorkSummaryService( + dependencies.rpgWorldProfiles, + dependencies.customWorldAgentSessions, ); + return service.list(userId); } diff --git a/server-node/src/services/questService.ts b/server-node/src/services/questService.ts index e3774f73..7bc8db97 100644 --- a/server-node/src/services/questService.ts +++ b/server-node/src/services/questService.ts @@ -1,11 +1,11 @@ -import type { QuestGenerationRequest } from '../../../packages/shared/src/contracts/story.js'; +import type { QuestGenerationRequest } from '../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js'; import { QUEST_INTIMACY_LEVELS, QUEST_NARRATIVE_TYPES, QUEST_OBJECTIVE_KINDS, QUEST_REWARD_THEMES, QUEST_URGENCY_LEVELS, -} from '../../../packages/shared/src/contracts/story.js'; +} from '../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js'; import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; import { buildFallbackQuestIntent, diff --git a/server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.test.ts b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.test.ts new file mode 100644 index 00000000..a2fcac97 --- /dev/null +++ b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.test.ts @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createRpgAgentSessionFixture } from '../../../../packages/shared/src/contracts/rpgCreationFixtures.js'; +import { applyCustomWorldAgentSessionCompatibility } from './rpgAgentSessionCompatibility.js'; + +function createLegacySessionRecord() { + const session = createRpgAgentSessionFixture(); + + return { + ...JSON.parse(JSON.stringify(session)), + userId: 'fixture-user', + seedText: '被海雾吞没的旧航路群岛', + operations: [], + checkpoints: [], + createdAt: session.updatedAt, + updatedAt: session.updatedAt, + }; +} + +test('session compatibility can backfill foundation_review state directly without store participation', () => { + const legacyRecord = createLegacySessionRecord(); + legacyRecord.stage = 'collecting_intent'; + legacyRecord.progressPercent = 0; + legacyRecord.currentTurn = 0; + legacyRecord.lastAssistantReply = null; + legacyRecord.anchorPack = {}; + legacyRecord.pendingClarifications = []; + legacyRecord.suggestedActions = []; + legacyRecord.recommendedReplies = []; + legacyRecord.creatorIntentReadiness = { + isReady: false, + completedKeys: [], + missingKeys: [], + }; + legacyRecord.anchorContent = {}; + legacyRecord.creatorIntent = { + rawSettingText: '', + worldHook: '被海雾吞没的旧航路群岛', + playerPremise: '玩家回到群岛调查沉船真相。', + openingSituation: '首夜就有陌生船只闯入禁航区。', + themeKeywords: ['压抑', '潮湿', '悬疑'], + toneDirectives: ['旧灯塔', '潮雾'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + keyCharacters: [ + { + id: 'playable-1', + name: '沈砺', + role: '旧航路引路人', + publicMask: '看上去像可靠旧友。', + hiddenHook: '暗中替沉船商盟引路。', + relationToPlayer: '旧友兼潜在背叛者', + notes: '关键同行者。', + }, + ], + iconicElements: ['回潮旧灯塔', '会移动的海雾'], + }; + legacyRecord.draftProfile = { + title: '潮雾列岛', + summary: '第一版世界底稿已经整理完成。', + playableNpcs: [ + { + id: 'playable-1', + name: '沈砺', + role: '旧航路引路人', + }, + ], + landmarks: [ + { + id: 'landmark-1', + name: '回潮旧灯塔', + description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。', + }, + ], + }; + + const normalized = applyCustomWorldAgentSessionCompatibility(legacyRecord); + + assert.equal(normalized.stage, 'foundation_review'); + assert.equal(normalized.progressPercent, 0); + assert.equal(normalized.creatorIntentReadiness.isReady, true); + assert.match( + normalized.lastAssistantReply ?? '', + /世界底稿已整理完成|结果页确认资产与发布门槛/u, + ); + assert.equal(normalized.pendingClarifications.length, 0); + assert.equal( + normalized.suggestedActions.some( + (entry) => entry.type === 'draft_foundation', + ), + true, + ); + assert.equal(normalized.anchorContent.worldPromise?.hook, '被海雾吞没的旧航路群岛'); + assert.equal(normalized.draftProfile.name, '潮雾列岛'); + assert.equal(normalized.assetCoverage.roleAssets.length, 1); +}); + +test('session compatibility can recover missing clarifications and anchor pack from sparse collecting records', () => { + const legacyRecord = createLegacySessionRecord(); + legacyRecord.seedText = ''; + legacyRecord.stage = 'collecting_intent'; + legacyRecord.progressPercent = Number.NaN; + legacyRecord.currentTurn = undefined; + legacyRecord.lastAssistantReply = undefined; + legacyRecord.anchorPack = null; + legacyRecord.pendingClarifications = []; + legacyRecord.suggestedActions = []; + legacyRecord.recommendedReplies = [1, '继续补世界一句话', null]; + legacyRecord.anchorContent = {}; + legacyRecord.creatorIntent = { + rawSettingText: '', + worldHook: '一个被潮雾反复切开的边境世界。', + playerPremise: '', + openingSituation: '', + themeKeywords: [], + toneDirectives: [], + coreConflicts: [], + keyCharacters: [], + iconicElements: [], + }; + legacyRecord.messages = [ + { + id: 'message-user', + role: 'user', + kind: 'chat', + text: '这个世界先定成一个被潮雾反复切开的边境世界。', + createdAt: legacyRecord.updatedAt, + relatedOperationId: null, + }, + { + id: 'message-assistant', + role: 'assistant', + kind: 'chat', + text: '你好!我是你的世界设定助手。', + createdAt: legacyRecord.updatedAt, + relatedOperationId: null, + }, + ]; + legacyRecord.draftProfile = { + title: '潮雾边境', + summary: '还在收集你的世界锚点。', + }; + + const normalized = applyCustomWorldAgentSessionCompatibility(legacyRecord); + + assert.equal(normalized.stage, 'clarifying'); + assert.ok(normalized.progressPercent > 0); + assert.equal(normalized.creatorIntentReadiness.isReady, false); + assert.ok(normalized.pendingClarifications.length > 0); + assert.ok(normalized.pendingClarifications[0]?.question); + assert.ok(normalized.anchorPack); + assert.deepEqual(normalized.recommendedReplies, ['继续补世界一句话']); + assert.ok( + normalized.suggestedActions.some( + (entry) => entry.type === 'request_summary', + ), + ); +}); diff --git a/server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.ts b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.ts new file mode 100644 index 00000000..be7ac766 --- /dev/null +++ b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.ts @@ -0,0 +1,443 @@ +import type { + CreatorIntentReadiness, + CustomWorldAgentStage, + CustomWorldAssetCoverageSummary, + CustomWorldPendingClarification, +} from '../../../../packages/shared/src/contracts/customWorldAgent.js'; +import { + buildPendingClarifications, + evaluateCreatorIntentReadiness, + resolveCreatorIntentStage, +} from '../customWorldAgentClarificationService.js'; +import { + normalizeCreatorIntentRecord, +} from '../customWorldAgentIntentExtractionService.js'; +import { rebuildRoleAssetCoverage } from '../customWorldAgentRoleAssetStateService.js'; +import { + buildAnchorPackFromEightAnchorContent, + buildCreatorIntentFromEightAnchorContent, + buildDraftSummaryFromEightAnchorContent, + buildDraftTitleFromEightAnchorContent, + buildEightAnchorContentFromCreatorIntent, + estimateProgressPercentFromAnchorContent, + normalizeEightAnchorContent, +} from '../eightAnchorCompatibilityService.js'; +import { + CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX, + type CustomWorldAgentSessionRecord, +} from './rpgAgentSessionRecord.js'; + +function toRecord(value: unknown) { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function toText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function isStage(value: unknown): value is CustomWorldAgentStage { + return ( + value === 'collecting_intent' || + value === 'clarifying' || + value === 'foundation_review' || + value === 'object_refining' || + value === 'visual_refining' || + value === 'long_tail_review' || + value === 'ready_to_publish' || + value === 'published' || + value === 'error' + ); +} + +export function isCustomWorldAgentSessionRecord( + value: unknown, +): value is CustomWorldAgentSessionRecord { + const record = toRecord(value); + if (!record) { + return false; + } + + return ( + typeof record.sessionId === 'string' && + record.sessionId.startsWith(CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX) && + typeof record.userId === 'string' && + isStage(record.stage) && + Array.isArray(record.messages) && + Array.isArray(record.operations) && + typeof record.createdAt === 'string' && + typeof record.updatedAt === 'string' + ); +} + +function isCreatorIntentReadiness( + value: unknown, +): value is CreatorIntentReadiness { + const record = toRecord(value); + if (!record) { + return false; + } + + return ( + typeof record.isReady === 'boolean' && + Array.isArray(record.completedKeys) && + Array.isArray(record.missingKeys) + ); +} + +function mapLegacyClarificationTargetKey(id: string) { + if (id === 'world_hook') return 'world_hook'; + if (id === 'player_premise') return 'player_premise'; + if (id === 'theme_and_tone' || id === 'tone_boundary') { + return 'theme_and_tone'; + } + if (id === 'core_conflict') return 'core_conflict'; + if (id === 'relationship_seed' || id === 'relationship_hook') { + return 'relationship_seed'; + } + if (id === 'iconic_element' || id === 'iconic_elements') { + return 'iconic_element'; + } + + return null; +} + +function hasUserInput(record: CustomWorldAgentSessionRecord) { + return ( + Boolean(record.seedText.trim()) || + record.messages.some( + (message) => message.role === 'user' && message.text.trim(), + ) + ); +} + +function buildCompatibleCreatorIntent(record: CustomWorldAgentSessionRecord) { + const compatibleAnchorIntent = buildCreatorIntentFromEightAnchorContent( + normalizeEightAnchorContent( + (record as Record).anchorContent ?? null, + ), + ); + + if ( + compatibleAnchorIntent && + (compatibleAnchorIntent.worldHook || + compatibleAnchorIntent.rawSettingText || + compatibleAnchorIntent.playerPremise || + compatibleAnchorIntent.openingSituation || + compatibleAnchorIntent.coreConflicts.length > 0 || + compatibleAnchorIntent.keyCharacters.length > 0 || + compatibleAnchorIntent.iconicElements.length > 0) + ) { + return compatibleAnchorIntent; + } + + return normalizeCreatorIntentRecord(record.creatorIntent); +} + +function buildCompatibleCurrentTurn(record: CustomWorldAgentSessionRecord) { + if (typeof (record as Record).currentTurn === 'number') { + return Math.max( + 0, + Math.round((record as Record).currentTurn as number), + ); + } + + return record.messages.filter((message) => message.role === 'user').length; +} + +function buildCompatibleAnchorContent(record: CustomWorldAgentSessionRecord) { + const normalized = normalizeEightAnchorContent( + (record as Record).anchorContent ?? null, + ); + + if ( + normalized.worldPromise || + normalized.playerFantasy || + normalized.themeBoundary || + normalized.playerEntryPoint || + normalized.coreConflict || + normalized.keyRelationships.length > 0 || + normalized.hiddenLines || + normalized.iconicElements + ) { + return normalized; + } + + return buildEightAnchorContentFromCreatorIntent( + buildCompatibleCreatorIntent(record), + ); +} + +function buildCompatibleProgressPercent(record: CustomWorldAgentSessionRecord) { + const rawProgress = (record as Record).progressPercent; + if (typeof rawProgress === 'number' && Number.isFinite(rawProgress)) { + return Math.max(0, Math.min(100, Math.round(rawProgress))); + } + + if ( + record.stage === 'foundation_review' || + record.stage === 'object_refining' || + record.stage === 'visual_refining' || + record.stage === 'long_tail_review' || + record.stage === 'ready_to_publish' || + record.stage === 'published' + ) { + return 100; + } + + return estimateProgressPercentFromAnchorContent( + buildCompatibleAnchorContent(record), + ); +} + +function buildCompatibleLastAssistantReply(record: CustomWorldAgentSessionRecord) { + const existingReply = (record as Record).lastAssistantReply; + if (typeof existingReply === 'string') { + return existingReply; + } + + const lastAssistantMessage = [...record.messages] + .reverse() + .find((message) => message.role === 'assistant' && message.text.trim()); + + return lastAssistantMessage?.text ?? null; +} + +function buildCompatiblePendingClarifications( + record: CustomWorldAgentSessionRecord, +) { + const normalizedIntent = normalizeCreatorIntentRecord(record.creatorIntent); + const readiness = buildCompatibleReadiness(record); + const legacyClarifications = Array.isArray(record.pendingClarifications) + ? record.pendingClarifications + : []; + + const nextClarifications = legacyClarifications + .map((entry, index) => { + const targetKey = mapLegacyClarificationTargetKey(entry.id); + if (!targetKey) { + return null; + } + + return { + id: entry.id || targetKey, + label: entry.label || '待补充问题', + question: entry.question || '', + targetKey, + priority: + typeof entry.priority === 'number' ? entry.priority : index + 1, + answer: entry.answer, + } satisfies CustomWorldPendingClarification; + }) + .filter((entry): entry is CustomWorldPendingClarification => + Boolean(entry?.question), + ) + .slice(0, 3); + + if (nextClarifications.length > 0) { + return nextClarifications; + } + + return buildPendingClarifications(normalizedIntent, readiness); +} + +function buildCompatibleReadiness(record: CustomWorldAgentSessionRecord) { + if ( + isCreatorIntentReadiness( + (record as Record).creatorIntentReadiness, + ) + ) { + return record.creatorIntentReadiness; + } + + return evaluateCreatorIntentReadiness( + normalizeCreatorIntentRecord(record.creatorIntent), + ); +} + +function buildCompatibleDraftProfile( + record: CustomWorldAgentSessionRecord, +) { + const anchorContent = buildCompatibleAnchorContent(record); + const existingDraftProfile = toRecord(record.draftProfile); + const hasFoundationContent = Boolean( + existingDraftProfile && + (typeof existingDraftProfile.name === 'string' || + Array.isArray(existingDraftProfile.playableNpcs) || + Array.isArray(existingDraftProfile.landmarks) || + Array.isArray(existingDraftProfile.factions) || + Array.isArray(existingDraftProfile.threads) || + Array.isArray(existingDraftProfile.chapters)), + ); + + if (hasFoundationContent) { + return { + ...existingDraftProfile, + name: + toText(existingDraftProfile?.name) || + toText(existingDraftProfile?.title) || + buildDraftTitleFromEightAnchorContent(anchorContent), + summary: + toText(existingDraftProfile?.summary) || + buildDraftSummaryFromEightAnchorContent(anchorContent), + }; + } + + return { + ...(existingDraftProfile ?? {}), + title: + toText(existingDraftProfile?.title) || + buildDraftTitleFromEightAnchorContent(anchorContent), + summary: + toText(existingDraftProfile?.summary) || + buildDraftSummaryFromEightAnchorContent(anchorContent), + }; +} + +function buildCompatibleSuggestedActions(params: { + record: CustomWorldAgentSessionRecord; + stage: CustomWorldAgentStage; + readiness: CreatorIntentReadiness; +}) { + if (params.record.suggestedActions.length > 0) { + // 旧快照里可能带有“精修对象”的 Agent 建议动作;当前 Agent 对话框只服务八锚点收集。 + const compatibleActions = params.record.suggestedActions.filter( + (action) => action.type !== 'refine_focus_target', + ); + if (compatibleActions.length > 0) { + return compatibleActions; + } + } + + const actions = [ + { + id: 'request_summary', + type: 'request_summary', + label: + params.stage === 'object_refining' || params.stage === 'visual_refining' + ? '总结当前世界底稿' + : '总结当前设定', + }, + ] as CustomWorldAgentSessionRecord['suggestedActions']; + + if (params.stage === 'foundation_review' && params.readiness.isReady) { + actions.push({ + id: 'draft_foundation', + type: 'draft_foundation', + label: '整理一版世界底稿', + }); + } + + return actions; +} + +function normalizeRecommendedReplies(value: unknown) { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item) => toText(item)) + .filter(Boolean) + .slice(0, 3); +} + +function buildCompatibleAssetCoverage( + record: CustomWorldAgentSessionRecord, + draftProfile: Record, +) { + const derivedCoverage = rebuildRoleAssetCoverage(draftProfile); + const existingCoverage = toRecord(record.assetCoverage); + const sceneAssets = + derivedCoverage.sceneAssets.length > 0 + ? derivedCoverage.sceneAssets + : Array.isArray(existingCoverage?.sceneAssets) + ? existingCoverage.sceneAssets + : []; + const allSceneAssetsReady = + derivedCoverage.sceneAssets.length > 0 + ? derivedCoverage.allSceneAssetsReady + : typeof existingCoverage?.allSceneAssetsReady === 'boolean' + ? existingCoverage.allSceneAssetsReady + : false; + + return { + ...derivedCoverage, + sceneAssets, + allSceneAssetsReady, + } satisfies CustomWorldAssetCoverageSummary; +} + +/** + * 兼容层集中收口旧 session 字段兜底,避免继续把兼容判断散落回 store 主逻辑。 + */ +export function applyCustomWorldAgentSessionCompatibility( + record: CustomWorldAgentSessionRecord, +) { + const creatorIntent = buildCompatibleCreatorIntent(record); + const currentTurn = buildCompatibleCurrentTurn(record); + const anchorContent = buildCompatibleAnchorContent(record); + const progressPercent = buildCompatibleProgressPercent(record); + const lastAssistantReply = buildCompatibleLastAssistantReply(record); + const creatorIntentReadiness = + progressPercent >= 100 + ? { + isReady: true, + completedKeys: [ + 'world_hook', + 'player_premise', + 'theme_and_tone', + 'core_conflict', + 'relationship_seed', + 'iconic_element', + ], + missingKeys: [], + } + : evaluateCreatorIntentReadiness(creatorIntent); + const stage = + record.stage === 'object_refining' || + record.stage === 'visual_refining' || + record.stage === 'long_tail_review' || + record.stage === 'ready_to_publish' || + record.stage === 'published' + ? record.stage + : progressPercent >= 100 + ? ('foundation_review' as const) + : resolveCreatorIntentStage({ + hasUserInput: hasUserInput(record), + readiness: creatorIntentReadiness, + }); + const pendingClarifications = buildCompatiblePendingClarifications({ + ...record, + creatorIntent, + creatorIntentReadiness, + }); + const draftProfile = buildCompatibleDraftProfile(record); + + return { + ...record, + currentTurn, + anchorContent, + progressPercent, + lastAssistantReply, + stage, + creatorIntent, + creatorIntentReadiness, + anchorPack: + record.anchorPack && Object.keys(record.anchorPack).length > 0 + ? record.anchorPack + : buildAnchorPackFromEightAnchorContent(anchorContent, progressPercent), + draftProfile, + pendingClarifications, + suggestedActions: buildCompatibleSuggestedActions({ + record, + stage, + readiness: creatorIntentReadiness, + }), + assetCoverage: buildCompatibleAssetCoverage(record, draftProfile), + recommendedReplies: normalizeRecommendedReplies( + (record as Record).recommendedReplies, + ), + } satisfies CustomWorldAgentSessionRecord; +} diff --git a/server-node/src/services/rpg-agent-session-store/rpgAgentSessionFactory.ts b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionFactory.ts new file mode 100644 index 00000000..aeaaebd2 --- /dev/null +++ b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionFactory.ts @@ -0,0 +1,74 @@ +import crypto from 'node:crypto'; + +import type { CustomWorldAgentMessage } from '../../../../packages/shared/src/contracts/customWorldAgent.js'; +import { rebuildRoleAssetCoverage } from '../customWorldAgentRoleAssetStateService.js'; +import { + createEmptyEightAnchorContent, + normalizeEightAnchorContent, +} from '../eightAnchorCompatibilityService.js'; +import { applyCustomWorldAgentSessionCompatibility } from './rpgAgentSessionCompatibility.js'; +import { + cloneRpgAgentSessionValue, + type CreateCustomWorldAgentSessionInput, + type CustomWorldAgentSessionRecord, + CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX, +} from './rpgAgentSessionRecord.js'; + +/** + * 新建 session 的初始值统一在这里生成,后续 store 只负责持久化与状态变更。 + */ +export function createCustomWorldAgentSessionRecord( + userId: string, + input: CreateCustomWorldAgentSessionInput, +) { + const sessionId = `${CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX}${crypto.randomBytes(16).toString('hex')}`; + const now = new Date().toISOString(); + const welcomeMessage: CustomWorldAgentMessage = { + id: `message-${crypto.randomBytes(8).toString('hex')}`, + role: 'assistant', + kind: 'chat', + text: input.welcomeMessage, + createdAt: now, + relatedOperationId: null, + }; + const record: CustomWorldAgentSessionRecord = { + sessionId, + userId, + seedText: input.seedText?.trim() ?? '', + currentTurn: Math.max(0, Math.round(input.currentTurn ?? 0)), + anchorContent: normalizeEightAnchorContent( + input.anchorContent ?? createEmptyEightAnchorContent(), + ), + progressPercent: Math.max( + 0, + Math.min(100, Math.round(input.progressPercent ?? 0)), + ), + lastAssistantReply: input.lastAssistantReply ?? input.welcomeMessage, + stage: input.stage ?? 'collecting_intent', + focusCardId: null, + creatorIntent: cloneRpgAgentSessionValue(input.creatorIntent ?? {}), + creatorIntentReadiness: input.creatorIntentReadiness ?? { + isReady: false, + completedKeys: [], + missingKeys: [], + }, + anchorPack: cloneRpgAgentSessionValue(input.anchorPack ?? {}), + lockState: {}, + draftProfile: cloneRpgAgentSessionValue(input.draftProfile ?? {}), + messages: [welcomeMessage], + draftCards: [], + pendingClarifications: cloneRpgAgentSessionValue( + input.pendingClarifications, + ), + suggestedActions: cloneRpgAgentSessionValue(input.suggestedActions), + recommendedReplies: cloneRpgAgentSessionValue(input.recommendedReplies ?? []), + qualityFindings: [], + assetCoverage: rebuildRoleAssetCoverage(input.draftProfile ?? {}), + operations: [], + checkpoints: [], + createdAt: now, + updatedAt: now, + }; + + return applyCustomWorldAgentSessionCompatibility(record); +} diff --git a/server-node/src/services/rpg-agent-session-store/rpgAgentSessionRecord.ts b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionRecord.ts new file mode 100644 index 00000000..6681c9f5 --- /dev/null +++ b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionRecord.ts @@ -0,0 +1,98 @@ +import type { + CreatorIntentReadiness, + CustomWorldAgentMessage, + CustomWorldAgentOperationRecord, + CustomWorldAgentStage, + CustomWorldAssetCoverageSummary, + CustomWorldDraftCardSummary, + CustomWorldPendingClarification, + CustomWorldSuggestedAction, + EightAnchorContent, +} from '../../../../packages/shared/src/contracts/customWorldAgent.js'; + +/** + * 当前阶段仍沿用旧 sessionId 前缀,避免影响已落库数据与前端恢复逻辑。 + */ +export const CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX = + 'custom-world-agent-session-'; + +export type CustomWorldAgentSessionRecord = { + sessionId: string; + userId: string; + seedText: string; + currentTurn: number; + anchorContent: EightAnchorContent; + progressPercent: number; + lastAssistantReply: string | null; + stage: CustomWorldAgentStage; + focusCardId: string | null; + creatorIntent: Record | null; + creatorIntentReadiness: CreatorIntentReadiness; + anchorPack: Record | null; + lockState: Record | null; + draftProfile: Record | null; + messages: CustomWorldAgentMessage[]; + draftCards: CustomWorldDraftCardSummary[]; + pendingClarifications: CustomWorldPendingClarification[]; + suggestedActions: CustomWorldSuggestedAction[]; + recommendedReplies: string[]; + qualityFindings: Array<{ + id: string; + severity: 'info' | 'warning' | 'blocker'; + code: string; + targetId?: string | null; + message: string; + }>; + assetCoverage: CustomWorldAssetCoverageSummary; + operations: CustomWorldAgentOperationRecord[]; + checkpoints: Array<{ + checkpointId: string; + createdAt: string; + label: string; + snapshot?: { + currentTurn: number; + anchorContent: EightAnchorContent; + progressPercent: number; + lastAssistantReply: string | null; + stage: CustomWorldAgentStage; + focusCardId: string | null; + creatorIntent: Record | null; + creatorIntentReadiness: CreatorIntentReadiness; + anchorPack: Record | null; + lockState: Record | null; + draftProfile: Record | null; + pendingClarifications: CustomWorldAgentSessionRecord['pendingClarifications']; + suggestedActions: CustomWorldAgentSessionRecord['suggestedActions']; + recommendedReplies: string[]; + draftCards: CustomWorldDraftCardSummary[]; + qualityFindings: CustomWorldAgentSessionRecord['qualityFindings']; + assetCoverage: CustomWorldAssetCoverageSummary; + } | null; + }>; + createdAt: string; + updatedAt: string; +}; + +export type CreateCustomWorldAgentSessionInput = { + seedText?: string; + welcomeMessage: string; + currentTurn?: number; + anchorContent?: EightAnchorContent; + progressPercent?: number; + lastAssistantReply?: string | null; + pendingClarifications: CustomWorldAgentSessionRecord['pendingClarifications']; + creatorIntent?: CustomWorldAgentSessionRecord['creatorIntent']; + creatorIntentReadiness?: CreatorIntentReadiness; + anchorPack?: CustomWorldAgentSessionRecord['anchorPack']; + draftProfile?: CustomWorldAgentSessionRecord['draftProfile']; + stage?: CustomWorldAgentStage; + suggestedActions: CustomWorldSuggestedAction[]; + recommendedReplies?: string[]; +}; + +/** + * session 记录里大量字段都是 JSON 结构,统一走结构化克隆可避免调用方误共享引用。 + */ +export function cloneRpgAgentSessionValue(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} diff --git a/server-node/src/services/rpg-agent-session-store/rpgAgentSessionRepositoryAdapter.ts b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionRepositoryAdapter.ts new file mode 100644 index 00000000..76153a53 --- /dev/null +++ b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionRepositoryAdapter.ts @@ -0,0 +1,24 @@ +import type { CustomWorldSessionRecord } from '../../../../packages/shared/src/contracts/runtime.js'; +import type { RpgAgentSessionRepositoryPort } from '../../repositories/RpgAgentSessionRepository.js'; + +export class RpgAgentSessionRepositoryAdapter { + constructor( + private readonly repository: RpgAgentSessionRepositoryPort, + ) {} + + async list(userId: string) { + return this.repository.listSessions(userId); + } + + async get(userId: string, sessionId: string) { + return this.repository.getSession(userId, sessionId); + } + + async upsert( + userId: string, + sessionId: string, + session: CustomWorldSessionRecord, + ) { + return this.repository.upsertSession(userId, sessionId, session); + } +} diff --git a/server-node/src/services/rpgCreationPreviewProfileBuilder.ts b/server-node/src/services/rpgCreationPreviewProfileBuilder.ts new file mode 100644 index 00000000..8dc76492 --- /dev/null +++ b/server-node/src/services/rpgCreationPreviewProfileBuilder.ts @@ -0,0 +1,349 @@ +import type { CustomWorldProfileRecord } from '../../../packages/shared/src/contracts/runtime.js'; +import { buildRpgWorldPreviewProfile } from './RpgWorldPreviewCompiler.js'; +import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; + +function toText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function toRecordArray(value: unknown) { + return Array.isArray(value) + ? value.filter( + (entry): entry is Record => + Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry), + ) + : []; +} + +function cloneRecord>(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function normalizeMatchText(value: unknown) { + return toText(value).toLocaleLowerCase(); +} + +function findUnusedMatchIndex( + records: Record[], + usedIndexes: Set, + matcher: (record: Record) => boolean, +) { + const matchedIndex = records.findIndex( + (record, index) => !usedIndexes.has(index) && matcher(record), + ); + if (matchedIndex >= 0) { + usedIndexes.add(matchedIndex); + } + return matchedIndex; +} + +function mergeDraftRolesIntoProfileRecord(params: { + baseRoles: unknown; + draftRoles: Array>; +}) { + const baseRoles = toRecordArray(params.baseRoles); + if (params.draftRoles.length <= 0) { + return baseRoles; + } + + const usedIndexes = new Set(); + return params.draftRoles.map((draftRole) => { + let matchedIndex = findUnusedMatchIndex( + baseRoles, + usedIndexes, + (record) => toText(record.id) === toText(draftRole.id), + ); + + if (matchedIndex < 0) { + matchedIndex = findUnusedMatchIndex( + baseRoles, + usedIndexes, + (record) => + normalizeMatchText(record.name) === normalizeMatchText(draftRole.name), + ); + } + + const baseRole = matchedIndex >= 0 ? baseRoles[matchedIndex] : null; + return { + ...(baseRole ?? {}), + ...draftRole, + imageSrc: toText(draftRole.imageSrc) || toText(baseRole?.imageSrc) || undefined, + generatedVisualAssetId: + toText(draftRole.generatedVisualAssetId) || + toText(baseRole?.generatedVisualAssetId) || + undefined, + generatedAnimationSetId: + toText(draftRole.generatedAnimationSetId) || + toText(baseRole?.generatedAnimationSetId) || + undefined, + animationMap: + isRecord(draftRole.animationMap) + ? draftRole.animationMap + : isRecord(baseRole?.animationMap) + ? baseRole.animationMap + : undefined, + } satisfies Record; + }); +} + +function mergeDraftLandmarksIntoProfileRecord(params: { + baseLandmarks: unknown; + draftLandmarks: Array>; +}) { + const baseLandmarks = toRecordArray(params.baseLandmarks); + if (params.draftLandmarks.length <= 0) { + return baseLandmarks; + } + + const usedIndexes = new Set(); + return params.draftLandmarks.map((draftLandmark) => { + let matchedIndex = findUnusedMatchIndex( + baseLandmarks, + usedIndexes, + (record) => toText(record.id) === toText(draftLandmark.id), + ); + + if (matchedIndex < 0) { + matchedIndex = findUnusedMatchIndex( + baseLandmarks, + usedIndexes, + (record) => + normalizeMatchText(record.name) === + normalizeMatchText(draftLandmark.name), + ); + } + + const baseLandmark = matchedIndex >= 0 ? baseLandmarks[matchedIndex] : null; + return { + ...(baseLandmark ?? {}), + ...draftLandmark, + imageSrc: + toText(draftLandmark.imageSrc) || toText(baseLandmark?.imageSrc) || undefined, + generatedSceneAssetId: + toText(draftLandmark.generatedSceneAssetId) || + toText(baseLandmark?.generatedSceneAssetId) || + undefined, + generatedScenePrompt: + toText(draftLandmark.generatedScenePrompt) || + toText(baseLandmark?.generatedScenePrompt) || + undefined, + generatedSceneModel: + toText(draftLandmark.generatedSceneModel) || + toText(baseLandmark?.generatedSceneModel) || + undefined, + } satisfies Record; + }); +} + +function mergeDraftSceneChaptersIntoProfileRecord(params: { + baseSceneChapters: unknown; + draftSceneChapters: unknown; +}) { + const baseSceneChapters = toRecordArray(params.baseSceneChapters); + const draftSceneChapters = toRecordArray(params.draftSceneChapters); + if (draftSceneChapters.length <= 0) { + return baseSceneChapters; + } + + const usedChapterIndexes = new Set(); + return draftSceneChapters.map((draftChapter) => { + let matchedIndex = findUnusedMatchIndex( + baseSceneChapters, + usedChapterIndexes, + (record) => toText(record.sceneId) === toText(draftChapter.sceneId), + ); + + if (matchedIndex < 0) { + matchedIndex = findUnusedMatchIndex( + baseSceneChapters, + usedChapterIndexes, + (record) => + normalizeMatchText(record.title) === normalizeMatchText(draftChapter.title), + ); + } + + const baseChapter = matchedIndex >= 0 ? baseSceneChapters[matchedIndex] : null; + const baseActs = toRecordArray(baseChapter?.acts); + const usedActIndexes = new Set(); + const mergedActs = toRecordArray(draftChapter.acts).map((draftAct) => { + let matchedActIndex = findUnusedMatchIndex( + baseActs, + usedActIndexes, + (record) => toText(record.id) === toText(draftAct.id), + ); + + if (matchedActIndex < 0) { + matchedActIndex = findUnusedMatchIndex( + baseActs, + usedActIndexes, + (record) => + normalizeMatchText(record.title) === + normalizeMatchText(draftAct.title), + ); + } + + const baseAct = matchedActIndex >= 0 ? baseActs[matchedActIndex] : null; + return { + ...(baseAct ?? {}), + ...draftAct, + backgroundImageSrc: + toText(draftAct.backgroundImageSrc) || + toText(baseAct?.backgroundImageSrc) || + undefined, + backgroundAssetId: + toText(draftAct.backgroundAssetId) || + toText(baseAct?.backgroundAssetId) || + undefined, + } satisfies Record; + }); + + return { + ...(baseChapter ?? {}), + ...draftChapter, + acts: mergedActs, + } satisfies Record; + }); +} + +function mergeDraftCampIntoProfileRecord(params: { + baseCamp: unknown; + draftCamp: unknown; +}) { + const draftCamp = isRecord(params.draftCamp) ? params.draftCamp : null; + if (!draftCamp) { + return isRecord(params.baseCamp) ? params.baseCamp : undefined; + } + + const baseCamp = isRecord(params.baseCamp) ? params.baseCamp : null; + return { + ...(baseCamp ?? {}), + ...draftCamp, + imageSrc: toText(draftCamp.imageSrc) || toText(baseCamp?.imageSrc) || undefined, + generatedSceneAssetId: + toText(draftCamp.generatedSceneAssetId) || + toText(baseCamp?.generatedSceneAssetId) || + undefined, + generatedScenePrompt: + toText(draftCamp.generatedScenePrompt) || + toText(baseCamp?.generatedScenePrompt) || + undefined, + generatedSceneModel: + toText(draftCamp.generatedSceneModel) || + toText(baseCamp?.generatedSceneModel) || + undefined, + } satisfies Record; +} + +function buildPreviewRawProfileSeed(params: { + sessionId: string; + profileId: string; + draftProfile: Record; +}) { + const foundationDraft = normalizeFoundationDraftProfile(params.draftProfile); + if (!foundationDraft) { + throw new Error('当前世界草稿为空,无法构建结果页预览。'); + } + + const legacyResultProfile = isRecord(params.draftProfile.legacyResultProfile) + ? cloneRecord(params.draftProfile.legacyResultProfile) + : null; + + const baseProfile = legacyResultProfile ?? { + id: params.profileId, + settingText: foundationDraft.worldHook, + name: foundationDraft.name, + subtitle: foundationDraft.subtitle, + summary: foundationDraft.summary, + tone: foundationDraft.tone, + playerGoal: foundationDraft.playerGoal, + templateWorldType: 'WUXIA', + majorFactions: foundationDraft.majorFactions, + coreConflicts: foundationDraft.coreConflicts, + playableNpcs: [], + storyNpcs: [], + items: [], + landmarks: [], + camp: null, + sceneChapterBlueprints: [], + generationMode: 'full', + generationStatus: 'complete', + }; + + return { + ...baseProfile, + id: params.profileId, + settingText: + toText(baseProfile.settingText) || foundationDraft.worldHook || foundationDraft.summary, + name: foundationDraft.name, + subtitle: foundationDraft.subtitle, + summary: foundationDraft.summary, + tone: foundationDraft.tone, + playerGoal: foundationDraft.playerGoal, + majorFactions: foundationDraft.majorFactions, + coreConflicts: foundationDraft.coreConflicts, + playableNpcs: mergeDraftRolesIntoProfileRecord({ + baseRoles: baseProfile.playableNpcs, + draftRoles: toRecordArray(params.draftProfile.playableNpcs), + }), + storyNpcs: mergeDraftRolesIntoProfileRecord({ + baseRoles: baseProfile.storyNpcs, + draftRoles: toRecordArray(params.draftProfile.storyNpcs), + }), + landmarks: mergeDraftLandmarksIntoProfileRecord({ + baseLandmarks: baseProfile.landmarks, + draftLandmarks: toRecordArray(params.draftProfile.landmarks), + }), + camp: mergeDraftCampIntoProfileRecord({ + baseCamp: baseProfile.camp, + draftCamp: params.draftProfile.camp, + }), + sceneChapterBlueprints: mergeDraftSceneChaptersIntoProfileRecord({ + baseSceneChapters: baseProfile.sceneChapterBlueprints, + draftSceneChapters: params.draftProfile.sceneChapters, + }), + creatorIntent: + (params.draftProfile.creatorIntent as Record | undefined) ?? + (baseProfile.creatorIntent as Record | undefined) ?? + null, + anchorPack: + (params.draftProfile.anchorPack as Record | undefined) ?? + (baseProfile.anchorPack as Record | undefined) ?? + null, + lockState: + (params.draftProfile.lockState as Record | undefined) ?? + (baseProfile.lockState as Record | undefined) ?? + null, + generationMode: 'full', + generationStatus: 'complete', + } satisfies Record; +} + +/** + * 结果页预览与正式发布统一走同一套“foundation draft + legacy 富字段合并”规则, + * 这样 Phase5 才能安全删除前端本地 fallback 编译桥。 + */ +export function buildRpgCreationPreviewProfileFromDraftProfile(params: { + sessionId: string; + draftProfile: Record; + profileId?: string; +}) { + const profileId = + toText(params.profileId) || + toText(params.draftProfile.legacyResultProfile?.id) || + `agent-draft-${params.sessionId}`; + const mergedProfile = buildPreviewRawProfileSeed({ + sessionId: params.sessionId, + profileId, + draftProfile: params.draftProfile, + }); + + return buildRpgWorldPreviewProfile( + mergedProfile, + toText(mergedProfile.settingText) || '', + ) as unknown as CustomWorldProfileRecord; +} + diff --git a/server-node/src/services/runtimeItemService.ts b/server-node/src/services/runtimeItemService.ts index 20b9bdfd..c8a21f7c 100644 --- a/server-node/src/services/runtimeItemService.ts +++ b/server-node/src/services/runtimeItemService.ts @@ -1,10 +1,10 @@ import { RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES, RUNTIME_ITEM_TONE_VALUES, -} from '../../../packages/shared/src/contracts/story.js'; +} from '../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js'; import type { RuntimeItemIntentRequest, -} from '../../../packages/shared/src/contracts/story.js'; +} from '../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js'; import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; import { buildRuntimeItemAiIntent, diff --git a/server-node/src/services/storyService.ts b/server-node/src/services/storyService.ts index fed1348e..e2a66612 100644 --- a/server-node/src/services/storyService.ts +++ b/server-node/src/services/storyService.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import type { StoryRequestPayload } from '../../../packages/shared/src/contracts/story.js'; +import type { StoryRequestPayload } from '../../../packages/shared/src/contracts/rpgRuntimeChat.js'; import { generateInitialStoryFromOrchestrator, generateNextStoryFromOrchestrator, diff --git a/src/App.tsx b/src/App.tsx index e780ec78..dc7ec6de 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,8 @@ -import { GameShellRuntime } from './components/game-shell/GameShellRuntime.tsx'; -import { useGameShellRuntime } from './hooks/useGameShellRuntime'; +import { RpgRuntimeShell } from './components/rpg-runtime-shell'; +import { useRpgRuntimeSession } from './hooks/rpg-session'; export default function App() { - const gameShellProps = useGameShellRuntime(); + const gameShellProps = useRpgRuntimeSession(); - return ; + return ; } diff --git a/src/components/AdventureEntityModal.tsx b/src/components/AdventureEntityModal.tsx index a531d811..034c282c 100644 --- a/src/components/AdventureEntityModal.tsx +++ b/src/components/AdventureEntityModal.tsx @@ -46,7 +46,7 @@ import { } from '../data/npcInteractions'; import { normalizePlayerProgressionState } from '../data/playerProgression'; import { getSceneHostileNpcPresetIds } from '../data/scenePresets'; -import type { CharacterChatTarget } from '../hooks/useStoryGeneration'; +import type { CharacterChatTarget } from '../hooks/rpg-runtime-story'; import { getResourceLabelsForWorld } from '../services/customWorldPresentation'; import { AnimationState, diff --git a/src/components/CharacterChatModal.tsx b/src/components/CharacterChatModal.tsx index dadcdaf6..5892f8c5 100644 --- a/src/components/CharacterChatModal.tsx +++ b/src/components/CharacterChatModal.tsx @@ -1,7 +1,7 @@ import { AnimatePresence, motion } from 'motion/react'; import { useEffect, useRef } from 'react'; -import type { CharacterChatModalState } from '../hooks/useStoryGeneration'; +import type { CharacterChatModalState } from '../hooks/rpg-runtime-story'; import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets'; import { PixelIcon } from './PixelIcon'; diff --git a/src/components/CharacterPanel.tsx b/src/components/CharacterPanel.tsx index 3475aeea..404253e2 100644 --- a/src/components/CharacterPanel.tsx +++ b/src/components/CharacterPanel.tsx @@ -27,7 +27,7 @@ import { getEquipmentSlotLabel, } from '../data/equipmentEffects'; import { buildMedievalNpcVisualFromCustomWorldVisual } from '../data/medievalNpcVisuals'; -import type { CharacterChatTarget } from '../hooks/useStoryGeneration'; +import type { CharacterChatTarget } from '../hooks/rpg-runtime-story'; import { getResourceLabelsForWorld } from '../services/customWorldPresentation'; import { AnimationState, diff --git a/src/components/CustomWorldEntityCatalog.tsx b/src/components/CustomWorldEntityCatalog.tsx index 1027b8eb..0972f933 100644 --- a/src/components/CustomWorldEntityCatalog.tsx +++ b/src/components/CustomWorldEntityCatalog.tsx @@ -29,7 +29,7 @@ import { } from '../types'; import { CharacterAnimator } from './CharacterAnimator'; import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork'; -import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal'; +import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal'; import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor'; export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks'; @@ -49,7 +49,7 @@ interface CustomWorldEntityCatalogProps { previewCharacters: Character[]; activeTab: ResultTab; onActiveTabChange: (tab: ResultTab) => void; - onEditTarget: (target: CustomWorldEditorTarget) => void; + onEditTarget: (target: RpgCreationEditorTarget) => void; onProfileChange: (profile: CustomWorldProfile) => void; onDeleteStoryNpcs?: (ids: string[]) => void; onDeleteLandmarks?: (ids: string[]) => void; diff --git a/src/components/CustomWorldEntityEditorModal.test.tsx b/src/components/CustomWorldEntityEditorModal.test.tsx index 5d25c973..d73301b1 100644 --- a/src/components/CustomWorldEntityEditorModal.test.tsx +++ b/src/components/CustomWorldEntityEditorModal.test.tsx @@ -12,10 +12,11 @@ import type { } from '../types'; import { CustomWorldEntityCatalog } from './CustomWorldEntityCatalog'; import { - type CustomWorldEditorTarget, - CustomWorldEntityEditorModal, -} from './CustomWorldEntityEditorModal'; + type RpgCreationEditorTarget, + RpgCreationEntityEditorModal, +} from './rpg-creation-editor/RpgCreationEntityEditorModal'; import * as customWorldCoverAssetService from '../services/customWorldCoverAssetService'; +import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient'; vi.mock('../data/characterPresets', async () => { const actual = await vi.importActual( @@ -37,12 +38,23 @@ vi.mock('./CharacterAnimator', () => ({ CharacterAnimator: () =>
角色预览
, })); -vi.mock('../services/aiService', () => ({ - generateCustomWorldSceneImage: vi.fn(), - generateCustomWorldSceneNpc: vi.fn(), - generateInitialStory: vi.fn(), - generateNextStep: vi.fn(), -})); +vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => { + const generateSceneImage = vi.fn(); + const generateSceneNpc = vi.fn(); + + return { + rpgCreationAssetClient: { + generateSceneImage, + generateSceneNpc, + }, + generateCustomWorldSceneImage: generateSceneImage, + generateCustomWorldSceneNpc: generateSceneNpc, + }; +}); + +const mockedRpgCreationAssetClient = vi.mocked( + rpgCreationAssetClient.rpgCreationAssetClient, +); vi.mock('./CustomWorldNpcVisualEditor', () => ({ CustomWorldNpcPortrait: ({ npc }: { npc: { name: string } }) => ( @@ -51,8 +63,8 @@ vi.mock('./CustomWorldNpcVisualEditor', () => ({ CustomWorldNpcVisualEditor: () =>
预设形象编辑器
, })); -vi.mock('./game-shell/GameShellRuntime', () => ({ - GameShellRuntime: ({ +vi.mock('./rpg-runtime-shell', () => ({ + RpgRuntimeShell: ({ session, }: { session: { gameState: { currentScenePreset?: { name?: string } | null } }; @@ -215,7 +227,7 @@ function createProfileWithLandmark(): CustomWorldProfile { function LandmarkEditorFlowHarness() { const [profile, setProfile] = useState(createProfileWithLandmark()); - const [target, setTarget] = useState({ + const [target, setTarget] = useState({ kind: 'landmark', mode: 'edit', id: 'landmark-1', @@ -236,7 +248,7 @@ function LandmarkEditorFlowHarness() { onDeleteStoryNpcs={() => {}} onDeleteLandmarks={() => {}} /> - setTarget(null)} @@ -278,7 +290,7 @@ function CampEditorFlowHarness() { ], }, }); - const [target, setTarget] = useState({ + const [target, setTarget] = useState({ kind: 'camp', }); @@ -297,7 +309,7 @@ function CampEditorFlowHarness() { onDeleteStoryNpcs={() => {}} onDeleteLandmarks={() => {}} /> - setTarget(null)} @@ -316,7 +328,7 @@ function CoverEditorFlowHarness() { characterRoleIds: ['playable-1'], }, }); - const [target, setTarget] = useState({ + const [target, setTarget] = useState({ kind: 'cover', }); @@ -325,7 +337,7 @@ function CoverEditorFlowHarness() {
         {JSON.stringify(profile)}
       
- setTarget(null)} @@ -350,7 +362,7 @@ test('playable角色打开AI工坊后不会自动关闭', async () => { const handleClose = vi.fn(); render( - { const handleClose = vi.fn(); render( - { const handleClose = vi.fn(); render( - { const handleClose = vi.fn(); render( - { const handleClose = vi.fn(); render( - { const handleClose = vi.fn(); render( - { }); test('场景图片保存后会同步更新编辑页和场景列表', async () => { - const aiService = await import('../services/aiService'); - vi.mocked(aiService.generateCustomWorldSceneImage).mockClear(); - vi.mocked(aiService.generateCustomWorldSceneImage).mockResolvedValue({ + mockedRpgCreationAssetClient.generateSceneImage.mockClear(); + mockedRpgCreationAssetClient.generateSceneImage.mockResolvedValue({ imageSrc: '/generated-custom-world-scenes/updated-scene.png', assetId: 'asset-1', model: 'wan2.2-t2i-flash', @@ -573,7 +584,7 @@ test('场景图片保存后会同步更新编辑页和场景列表', async () => await user.click(screen.getByRole('button', { name: '开始生成' })); await waitFor(() => { - expect(aiService.generateCustomWorldSceneImage).toHaveBeenCalledTimes(1); + expect(mockedRpgCreationAssetClient.generateSceneImage).toHaveBeenCalledTimes(1); }); await user.click(screen.getByRole('button', { name: '保存' })); @@ -609,9 +620,8 @@ test('场景图片保存后会同步更新编辑页和场景列表', async () => }); test('开局场景图片保存后会同步更新编辑页和场景列表', async () => { - const aiService = await import('../services/aiService'); - vi.mocked(aiService.generateCustomWorldSceneImage).mockClear(); - vi.mocked(aiService.generateCustomWorldSceneImage).mockResolvedValue({ + mockedRpgCreationAssetClient.generateSceneImage.mockClear(); + mockedRpgCreationAssetClient.generateSceneImage.mockResolvedValue({ imageSrc: '/generated-custom-world-scenes/updated-camp.png', assetId: 'asset-camp-1', model: 'wan2.2-t2i-flash', @@ -644,7 +654,7 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async await user.click(screen.getByRole('button', { name: '开始生成' })); await waitFor(() => { - expect(aiService.generateCustomWorldSceneImage).toHaveBeenCalledTimes(1); + expect(mockedRpgCreationAssetClient.generateSceneImage).toHaveBeenCalledTimes(1); }); await user.click(screen.getByRole('button', { name: '保存' })); diff --git a/src/components/CustomWorldResultView.test.tsx b/src/components/CustomWorldResultView.test.tsx index 05fe3658..7db2c2c4 100644 --- a/src/components/CustomWorldResultView.test.tsx +++ b/src/components/CustomWorldResultView.test.tsx @@ -6,13 +6,35 @@ import { useState } from 'react'; import { expect, test, vi } from 'vitest'; import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types'; -import { CustomWorldResultView } from './CustomWorldResultView'; +import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient'; +import { RpgCreationResultView } from './rpg-creation-result/RpgCreationResultView'; -vi.mock('../services/aiService', () => ({ - generateCustomWorldPlayableNpc: vi.fn(), - generateCustomWorldStoryNpc: vi.fn(), - generateCustomWorldLandmark: vi.fn(), -})); +vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => { + const generatePlayableNpc = vi.fn(); + const generateStoryNpc = vi.fn(); + const generateLandmark = vi.fn(); + const generateSceneImage = vi.fn(); + const generateSceneNpc = vi.fn(); + + return { + rpgCreationAssetClient: { + generatePlayableNpc, + generateStoryNpc, + generateLandmark, + generateSceneImage, + generateSceneNpc, + }, + generateCustomWorldPlayableNpc: generatePlayableNpc, + generateCustomWorldStoryNpc: generateStoryNpc, + generateCustomWorldLandmark: generateLandmark, + generateCustomWorldSceneImage: generateSceneImage, + generateCustomWorldSceneNpc: generateSceneNpc, + }; +}); + +const mockedRpgCreationAssetClient = vi.mocked( + rpgCreationAssetClient.rpgCreationAssetClient, +); vi.mock('./CharacterAnimator', () => ({ CharacterAnimator: () =>
角色预览
, @@ -24,15 +46,11 @@ vi.mock('./CustomWorldNpcVisualEditor', () => ({ ), })); -vi.mock('./CustomWorldEntityEditorModal', () => ({ - CustomWorldEntityEditorModal: () => null, +vi.mock('./rpg-creation-editor/RpgCreationEntityEditorModal', () => ({ + RpgCreationEntityEditorModal: () => null, default: () => null, })); -async function loadAiService() { - return import('../services/aiService'); -} - function createBackstoryReveal() { return { publicSummary: '公开背景', @@ -259,7 +277,7 @@ function ResultViewHarness() { const [profile, setProfile] = useState(baseProfile); return ( - { - const aiService = await loadAiService(); const user = userEvent.setup(); let resolveGeneration: ((value: CustomWorldPlayableNpc) => void) | null = null; - vi.mocked(aiService.generateCustomWorldPlayableNpc).mockImplementation( + mockedRpgCreationAssetClient.generatePlayableNpc.mockImplementation( () => new Promise((resolve) => { resolveGeneration = resolve; @@ -346,7 +363,7 @@ test('playable tab prefers generated portrait over runtime preview placeholder', } as CustomWorldProfile; render( - { + render( + {}} + onProfileChange={() => {}} + compactAgentResultMode + publishReady={false} + publishBlockers={[ + '仍有角色缺少正式主图或动作资产,发布前需要先补齐。', + '营地还缺少正式场景图资产,发布前需要先确认营地图。', + ]} + qualityFindings={[ + { + id: 'role-assets-pending', + severity: 'warning', + code: 'role_assets_pending', + message: '仍有角色资产未完全补齐。', + }, + ]} + previewSourceLabel="服务端预览" + enterWorldActionLabel="发布并进入世界" + onEnterWorld={() => {}} + />, + ); + + expect(screen.getByText(/当前结果页数据源:服务端预览/u)).toBeTruthy(); + expect(screen.getByText(/当前还有 2 个发布阻断项/u)).toBeTruthy(); + expect( + screen.getByText(/仍有角色缺少正式主图或动作资产/u), + ).toBeTruthy(); + const actionButton = screen.getByRole('button', { + name: '发布并进入世界', + }); + expect((actionButton as HTMLButtonElement).disabled).toBe(true); +}); + +test('agent result view keeps publish-enter action enabled when publish gate is clear', () => { + render( + {}} + onProfileChange={() => {}} + compactAgentResultMode + publishReady + publishBlockers={[]} + qualityFindings={[ + { + id: 'scene-assets-pending', + severity: 'warning', + code: 'scene_assets_pending', + message: '仍有场景分幕图未补齐。', + }, + ]} + previewSourceLabel="服务端预览" + enterWorldActionLabel="发布并进入世界" + onEnterWorld={() => {}} + />, + ); + + expect(screen.getByText(/发布后仍有 1 条 warning 可继续优化/u)).toBeTruthy(); + const actionButton = screen.getByRole('button', { + name: '发布并进入世界', + }); + expect((actionButton as HTMLButtonElement).disabled).toBe(false); +}); diff --git a/src/components/CustomWorldResultView.tsx b/src/components/CustomWorldResultView.tsx deleted file mode 100644 index ce4e89fc..00000000 --- a/src/components/CustomWorldResultView.tsx +++ /dev/null @@ -1,791 +0,0 @@ -import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; - -import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph'; -import { - generateCustomWorldLandmark, - generateCustomWorldPlayableNpc, - generateCustomWorldStoryNpc, -} from '../services/aiService'; -import { - Character, - CustomWorldLandmark, - CustomWorldNpc, - CustomWorldPlayableNpc, - CustomWorldProfile, -} from '../types'; -import { - CustomWorldEntityCatalog, - type ResultTab, -} from './CustomWorldEntityCatalog'; -import CustomWorldEntityEditorModal, { - type CustomWorldEditorTarget, -} from './CustomWorldEntityEditorModal'; - -interface CustomWorldResultViewProps { - profile: CustomWorldProfile; - previewCharacters: Character[]; - isGenerating: boolean; - progress: number; - progressLabel: string; - error: string | null; - onBack: () => void; - onEditSetting?: () => void; - onRegenerate?: () => void; - onContinueExpand?: () => void; - onEnterWorld?: () => void; - onProfileChange: (profile: CustomWorldProfile) => void; - readOnly?: boolean; - backLabel?: string; - editActionLabel?: string; - regenerateActionLabel?: string; - enterWorldActionLabel?: string; - autoSaveState?: 'idle' | 'saving' | 'saved' | 'error'; - compactAgentResultMode?: boolean; -} - -type EntityGenerationKind = 'playable' | 'story' | 'landmark'; - -type PendingGeneratedEntity = { - id: string; - kind: EntityGenerationKind; - title: string; - progress: number; - phaseLabel: string; -}; - -type RecentGeneratedIds = Record; - -type CustomWorldAssetDebugEntry = { - id: string; - label: string; - imageSrc: string; - kind: 'playable' | 'story' | 'landmark' | 'scene-act'; -}; - -type AssetDebugLoadStatus = 'loading' | 'loaded' | 'error'; - -const CUSTOM_WORLD_ASSET_DEBUG_QUERY_KEY = 'debugCustomWorldAssets'; -const CUSTOM_WORLD_ASSET_DEBUG_STORAGE_KEY = - 'genarrative.debug.customWorldAssets'; - -function shouldEnableCustomWorldAssetDebugPanel() { - if (!import.meta.env.DEV || typeof window === 'undefined') { - return false; - } - - const searchParams = new URLSearchParams(window.location.search); - if (searchParams.get(CUSTOM_WORLD_ASSET_DEBUG_QUERY_KEY) === '1') { - return true; - } - - return ( - window.localStorage.getItem(CUSTOM_WORLD_ASSET_DEBUG_STORAGE_KEY) === '1' - ); -} - -function collectCustomWorldAssetDebugEntries( - profile: CustomWorldProfile, -): CustomWorldAssetDebugEntry[] { - const playableEntries = profile.playableNpcs - .map((role) => { - const imageSrc = role.imageSrc?.trim() || ''; - if (!imageSrc) { - return null; - } - - return { - id: `playable:${role.id}`, - label: `${role.name}主形象`, - imageSrc, - kind: 'playable' as const, - }; - }) - .filter( - (entry): entry is CustomWorldAssetDebugEntry => Boolean(entry), - ); - const storyEntries = profile.storyNpcs - .map((role) => { - const imageSrc = role.imageSrc?.trim() || ''; - if (!imageSrc) { - return null; - } - - return { - id: `story:${role.id}`, - label: `${role.name}场景角色主图`, - imageSrc, - kind: 'story' as const, - }; - }) - .filter( - (entry): entry is CustomWorldAssetDebugEntry => Boolean(entry), - ); - const landmarkEntries = profile.landmarks - .map((landmark) => { - const imageSrc = landmark.imageSrc?.trim() || ''; - if (!imageSrc) { - return null; - } - - return { - id: `landmark:${landmark.id}`, - label: `${landmark.name}场景主图`, - imageSrc, - kind: 'landmark' as const, - }; - }) - .filter( - (entry): entry is CustomWorldAssetDebugEntry => Boolean(entry), - ); - const sceneActEntries = - profile.sceneChapterBlueprints?.flatMap((chapter) => - chapter.acts - .map((act) => { - const imageSrc = act.backgroundImageSrc?.trim() || ''; - if (!imageSrc) { - return null; - } - - return { - id: `scene-act:${chapter.id}:${act.id}`, - label: `${chapter.title || chapter.sceneId} / ${act.title}幕图`, - imageSrc, - kind: 'scene-act' as const, - }; - }) - .filter( - (entry): entry is CustomWorldAssetDebugEntry => Boolean(entry), - ), - ) ?? []; - - return [ - ...playableEntries, - ...storyEntries, - ...landmarkEntries, - ...sceneActEntries, - ]; -} - -function resolveAssetDebugStatusLabel(status: AssetDebugLoadStatus | undefined) { - if (status === 'loaded') { - return '已加载'; - } - if (status === 'error') { - return '加载失败'; - } - return '检测中'; -} - -function resolveAssetDebugSummary(profile: CustomWorldProfile) { - return [ - { - label: '可扮演角色主图', - value: `${profile.playableNpcs.filter((role) => Boolean(role.imageSrc?.trim())).length}/${profile.playableNpcs.length}`, - }, - { - label: '场景角色主图', - value: `${profile.storyNpcs.filter((role) => Boolean(role.imageSrc?.trim())).length}/${profile.storyNpcs.length}`, - }, - { - label: '场景主图', - value: `${profile.landmarks.filter((landmark) => Boolean(landmark.imageSrc?.trim())).length}/${profile.landmarks.length}`, - }, - { - label: '分幕图', - value: `${profile.sceneChapterBlueprints?.reduce( - (sum, chapter) => - sum + - chapter.acts.filter((act) => Boolean(act.backgroundImageSrc?.trim())) - .length, - 0, - ) ?? 0}/${ - profile.sceneChapterBlueprints?.reduce( - (sum, chapter) => sum + chapter.acts.length, - 0, - ) ?? 0 - }`, - }, - ]; -} - -function SmallButton({ - onClick, - children, - tone = 'default', - disabled = false, -}: { - onClick: () => void; - children: ReactNode; - tone?: 'default' | 'sky'; - disabled?: boolean; -}) { - return ( - - ); -} - -function getCreateTargetByTab( - activeTab: ResultTab, -): CustomWorldEditorTarget | null { - if (activeTab === 'playable') return { kind: 'playable', mode: 'create' }; - if (activeTab === 'story') return { kind: 'story', mode: 'create' }; - if (activeTab === 'landmarks') return { kind: 'landmark', mode: 'create' }; - return null; -} - -function getCreateLabelByTab(activeTab: ResultTab) { - if (activeTab === 'playable') return '新增可扮演角色'; - if (activeTab === 'story') return '新增场景角色'; - if (activeTab === 'landmarks') return '新增场景'; - return ''; -} - -function createPendingGeneratedEntity( - kind: EntityGenerationKind, -): PendingGeneratedEntity { - return { - id: `pending-${kind}-${Date.now()}`, - kind, - title: - kind === 'playable' - ? '新可扮演角色' - : kind === 'story' - ? '新场景角色' - : '新场景', - progress: 8, - phaseLabel: '正在整理世界上下文', - }; -} - -function resolvePendingPhaseLabel( - kind: EntityGenerationKind, - progress: number, -) { - if (progress < 28) { - return '正在整理世界上下文'; - } - if (progress < 72) { - return kind === 'landmark' ? '正在推理场景结构' : '正在推理角色结构'; - } - return '正在回写结果'; -} - -function prependPlayableNpc( - profile: CustomWorldProfile, - npc: CustomWorldPlayableNpc, -) { - return { - ...profile, - playableNpcs: [npc, ...profile.playableNpcs], - } satisfies CustomWorldProfile; -} - -function prependStoryNpc(profile: CustomWorldProfile, npc: CustomWorldNpc) { - return { - ...profile, - storyNpcs: [npc, ...profile.storyNpcs], - } satisfies CustomWorldProfile; -} - -function prependLandmark( - profile: CustomWorldProfile, - landmark: CustomWorldLandmark, -) { - return { - ...profile, - landmarks: normalizeCustomWorldLandmarks({ - landmarks: [landmark, ...profile.landmarks], - storyNpcs: profile.storyNpcs, - }), - } satisfies CustomWorldProfile; -} - -function removeStoryNpcsFromProfile( - profile: CustomWorldProfile, - ids: string[], -) { - const idSet = new Set(ids); - const nextStoryNpcs = profile.storyNpcs.filter((npc) => !idSet.has(npc.id)); - - return { - ...profile, - storyNpcs: nextStoryNpcs, - landmarks: normalizeCustomWorldLandmarks({ - landmarks: profile.landmarks.map((landmark) => ({ - ...landmark, - sceneNpcIds: landmark.sceneNpcIds.filter((npcId) => !idSet.has(npcId)), - })), - storyNpcs: nextStoryNpcs, - }), - } satisfies CustomWorldProfile; -} - -function removeLandmarksFromProfile( - profile: CustomWorldProfile, - ids: string[], -) { - const idSet = new Set(ids); - const nextLandmarks = profile.landmarks.filter( - (landmark) => !idSet.has(landmark.id), - ); - - return { - ...profile, - landmarks: normalizeCustomWorldLandmarks({ - landmarks: nextLandmarks.map((landmark) => ({ - ...landmark, - connections: landmark.connections.filter( - (connection) => !idSet.has(connection.targetLandmarkId), - ), - })), - storyNpcs: profile.storyNpcs, - }), - } satisfies CustomWorldProfile; -} - -export function CustomWorldResultView({ - profile, - previewCharacters, - isGenerating, - progress, - progressLabel, - error, - onBack, - onEditSetting, - onRegenerate: triggerRegenerate, - onContinueExpand, - onEnterWorld, - onProfileChange, - readOnly = false, - backLabel = '返回', - editActionLabel = '修改设定', - regenerateActionLabel = '重新生成', - enterWorldActionLabel = '进入世界', - autoSaveState = 'idle', - compactAgentResultMode = false, -}: CustomWorldResultViewProps) { - const [editorTarget, setEditorTarget] = - useState(null); - const [activeTab, setActiveTab] = useState('world'); - const [pendingGeneratedEntity, setPendingGeneratedEntity] = - useState(null); - const [recentGeneratedIds, setRecentGeneratedIds] = useState( - { - playable: [], - story: [], - landmark: [], - }, - ); - const [localGenerationError, setLocalGenerationError] = useState( - null, - ); - const pendingProgressTimerRef = useRef(null); - const assetDebugEnabled = useMemo( - () => shouldEnableCustomWorldAssetDebugPanel(), - [], - ); - const assetDebugEntries = useMemo( - () => - assetDebugEnabled ? collectCustomWorldAssetDebugEntries(profile) : [], - [assetDebugEnabled, profile], - ); - const assetDebugSummary = useMemo( - () => (assetDebugEnabled ? resolveAssetDebugSummary(profile) : []), - [assetDebugEnabled, profile], - ); - const [assetDebugStatusMap, setAssetDebugStatusMap] = useState< - Record - >({}); - - const createTarget = useMemo( - () => getCreateTargetByTab(activeTab), - [activeTab], - ); - const createLabel = useMemo( - () => getCreateLabelByTab(activeTab), - [activeTab], - ); - const stopPendingProgressTimer = () => { - if (pendingProgressTimerRef.current !== null) { - window.clearInterval(pendingProgressTimerRef.current); - pendingProgressTimerRef.current = null; - } - }; - - useEffect(() => () => stopPendingProgressTimer(), []); - - useEffect(() => { - if (!assetDebugEnabled) { - setAssetDebugStatusMap({}); - return; - } - - if (assetDebugEntries.length === 0) { - setAssetDebugStatusMap({}); - return; - } - - let cancelled = false; - const cleanupList: Array<() => void> = []; - - setAssetDebugStatusMap( - Object.fromEntries( - assetDebugEntries.map((entry) => [entry.id, 'loading' as const]), - ), - ); - - assetDebugEntries.forEach((entry) => { - const image = new Image(); - const updateStatus = (status: AssetDebugLoadStatus) => { - if (cancelled) { - return; - } - - setAssetDebugStatusMap((current) => { - if (current[entry.id] === status) { - return current; - } - return { - ...current, - [entry.id]: status, - }; - }); - }; - - image.onload = () => updateStatus('loaded'); - image.onerror = () => updateStatus('error'); - image.src = entry.imageSrc; - cleanupList.push(() => { - image.onload = null; - image.onerror = null; - }); - }); - - return () => { - cancelled = true; - cleanupList.forEach((cleanup) => cleanup()); - }; - }, [assetDebugEnabled, assetDebugEntries]); - - const startPendingProgress = (kind: EntityGenerationKind) => { - stopPendingProgressTimer(); - setPendingGeneratedEntity(createPendingGeneratedEntity(kind)); - pendingProgressTimerRef.current = window.setInterval(() => { - setPendingGeneratedEntity((current) => { - if (!current || current.kind !== kind) { - return current; - } - - const nextProgress = Math.min( - current.progress + (current.progress < 56 ? 11 : 5), - 88, - ); - - return { - ...current, - progress: nextProgress, - phaseLabel: resolvePendingPhaseLabel(kind, nextProgress), - }; - }); - }, 520); - }; - - const finishPendingProgress = () => { - stopPendingProgressTimer(); - setPendingGeneratedEntity(null); - }; - - const markGeneratedAsRecent = ( - kind: EntityGenerationKind, - generatedId: string, - ) => { - setRecentGeneratedIds((current) => ({ - ...current, - [kind]: [generatedId, ...current[kind].filter((id) => id !== generatedId)].slice( - 0, - 6, - ), - })); - }; - - const handleGenerateEntity = async (kind: EntityGenerationKind) => { - if (readOnly || isGenerating || pendingGeneratedEntity) { - return; - } - - setLocalGenerationError(null); - startPendingProgress(kind); - - try { - if (kind === 'playable') { - const nextNpc = await generateCustomWorldPlayableNpc({ profile }); - onProfileChange(prependPlayableNpc(profile, nextNpc)); - markGeneratedAsRecent('playable', nextNpc.id); - } else if (kind === 'story') { - const nextNpc = await generateCustomWorldStoryNpc({ profile }); - onProfileChange(prependStoryNpc(profile, nextNpc)); - markGeneratedAsRecent('story', nextNpc.id); - } else { - const nextLandmark = await generateCustomWorldLandmark({ profile }); - onProfileChange(prependLandmark(profile, nextLandmark)); - markGeneratedAsRecent('landmark', nextLandmark.id); - } - } catch (generationError) { - setLocalGenerationError( - generationError instanceof Error - ? generationError.message - : '生成失败,请稍后重试。', - ); - } finally { - finishPendingProgress(); - } - }; - - const onRegenerate = () => { - if (isGenerating || !triggerRegenerate) return; - - const confirmed = window.confirm( - `确认重新生成“${profile.name}”吗?\n\n重新生成会重新生成当前世界中的所有信息,包括你修改和新增的所有内容。`, - ); - if (!confirmed) return; - - triggerRegenerate(); - }; - - const handleDeleteStoryNpcs = (ids: string[]) => { - if (ids.length === 0) return; - onProfileChange(removeStoryNpcsFromProfile(profile, ids)); - }; - - const handleDeleteLandmarks = (ids: string[]) => { - if (ids.length === 0) return; - onProfileChange(removeLandmarksFromProfile(profile, ids)); - }; - const autoSaveBadge = - autoSaveState === 'saved' ? ( -
- 已自动保存 -
- ) : autoSaveState === 'saving' ? ( -
- 保存中 -
- ) : autoSaveState === 'error' ? ( -
- 保存失败 -
- ) : null; - - return ( -
-
- - {autoSaveBadge} -
- -
- { - if (activeTab === 'playable') { - void handleGenerateEntity('playable'); - return; - } - if (activeTab === 'story') { - void handleGenerateEntity('story'); - return; - } - if (activeTab === 'landmarks') { - void handleGenerateEntity('landmark'); - return; - } - setEditorTarget(createTarget); - } - } - createActionDisabled={Boolean( - isGenerating || pendingGeneratedEntity, - )} - pendingGeneratedEntity={pendingGeneratedEntity} - recentGeneratedIds={recentGeneratedIds} - readOnly={readOnly} - /> -
- - {isGenerating && ( -
-
-
- {progressLabel} -
-
- {Math.round(progress)}% -
-
-
-
-
-
- )} - - {error ? ( -
- {error} -
- ) : null} - {!error && localGenerationError ? ( -
- {localGenerationError} -
- ) : null} - {assetDebugEnabled ? ( -
-
-
-
- 资产诊断 -
-
- 仅开发模式显示,用来核对结果页当前拿到的图片字段和实际加载状态。 -
-
-
- {assetDebugEntries.length}项 -
-
-
- {assetDebugSummary.map((entry) => ( -
-
{entry.label}
-
- {entry.value} -
-
- ))} -
-
- {assetDebugEntries.length > 0 ? ( - assetDebugEntries.map((entry) => ( -
-
-
-
- {entry.label} -
-
- {entry.imageSrc} -
-
-
- {resolveAssetDebugStatusLabel( - assetDebugStatusMap[entry.id], - )} -
-
- -
- )) - ) : ( -
- 当前结果页 profile 里没有拿到任何可诊断的图片地址。 -
- )} -
-
- ) : null} - -
- {profile.generationStatus === 'key_only' ? ( -
- 当前世界处于快速预览模式,只生成了关键对象。继续补全后,系统会生成长尾场景角色与完整场景网络。 -
- ) : null} -
- {onEditSetting ? ( - {editActionLabel} - ) : null} - {triggerRegenerate ? ( - - {regenerateActionLabel} - - ) : null} - {profile.generationStatus === 'key_only' && onContinueExpand ? ( - - 继续补全世界 - - ) : null} - {onEnterWorld ? ( - - ) : null} -
-
- - setEditorTarget(null)} - onProfileChange={onProfileChange} - /> -
- ); -} diff --git a/src/components/NpcModals.tsx b/src/components/NpcModals.tsx index e5feed34..f2c90a13 100644 --- a/src/components/NpcModals.tsx +++ b/src/components/NpcModals.tsx @@ -23,7 +23,7 @@ import { getGiftCandidates, getRarityLabel, } from '../data/npcInteractions'; -import { StoryGenerationNpcUi } from '../hooks/useStoryGeneration'; +import { StoryGenerationNpcUi } from '../hooks/rpg-runtime-story'; import { GameState, InventoryItem } from '../types'; import { CHROME_ICONS, getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets'; import { PixelIcon } from './PixelIcon'; diff --git a/src/components/game-shell/PreGameSelectionFlow.tsx b/src/components/game-shell/PreGameSelectionFlow.tsx deleted file mode 100644 index 2357d18a..00000000 --- a/src/components/game-shell/PreGameSelectionFlow.tsx +++ /dev/null @@ -1,1920 +0,0 @@ -import { AnimatePresence, motion } from 'motion/react'; -import { - lazy, - Suspense, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; - -import type { - CustomWorldAgentActionRequest, - CustomWorldAgentMessage, - CustomWorldAgentOperationRecord, - CustomWorldAgentSessionSnapshot, - CustomWorldWorkSummary, - SendCustomWorldAgentMessageRequest, -} from '../../../packages/shared/src/contracts/customWorldAgent'; -import type { - CustomWorldGalleryCard, - CustomWorldLibraryEntry, - PlatformBrowseHistoryEntry, - PlatformBrowseHistoryWriteEntry, - ProfileDashboardSummary, - ProfileSaveArchiveSummary, -} from '../../../packages/shared/src/contracts/runtime'; -import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; -import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; -import { - createCustomWorldAgentSession, - executeCustomWorldAgentAction, - getCustomWorldAgentOperation, - getCustomWorldAgentSession, - listCustomWorldWorks, - streamCustomWorldAgentMessage, -} from '../../services/aiService'; -import { buildCustomWorldProfileFromAgentDraft } from '../../services/customWorldAgentDraftResult'; -import { - buildAgentDraftFoundationAnchorEntries, - buildAgentDraftFoundationGenerationProgress, - buildAgentDraftFoundationSettingText, - isDraftFoundationOperation, - isDraftFoundationOperationRunning, -} from '../../services/customWorldAgentGenerationProgress'; -import { - readCustomWorldAgentUiState, - writeCustomWorldAgentUiState, -} from '../../services/customWorldAgentUiState'; -import { buildCustomWorldCreatorIntentFoundationText } from '../../services/customWorldCreatorIntent'; -import { - deleteCustomWorldProfile, - getCustomWorldGalleryDetail, - getProfileDashboard, - listCustomWorldGallery, - listCustomWorldLibrary, - listProfileBrowseHistory, - listProfileSaveArchives, - publishCustomWorldProfile, - resumeProfileSaveArchive, - unpublishCustomWorldProfile, - upsertCustomWorldProfile, - upsertProfileBrowseHistory, -} from '../../services/storageService'; -import { type CustomWorldProfile, type GameState } from '../../types'; -import { useAuthUi } from '../auth/AuthUiContext'; -import { CustomWorldCreationHub } from '../custom-world-home/CustomWorldCreationHub'; -import { PlatformCreationTypeModal } from './PlatformCreationTypeModal'; -import { type PlatformHomeTab, PlatformHomeView } from './PlatformHomeView'; -import { PlatformWorldDetailView } from './PlatformWorldDetailView'; - -const CustomWorldGenerationView = lazy(async () => { - const module = await import('../CustomWorldGenerationView'); - return { - default: module.CustomWorldGenerationView, - }; -}); - -const CustomWorldResultView = lazy(async () => { - const module = await import('../CustomWorldResultView'); - return { - default: module.CustomWorldResultView, - }; -}); - -const CustomWorldAgentWorkspace = lazy(async () => { - const module = await import( - '../custom-world-agent/CustomWorldAgentWorkspace' - ); - return { - default: module.CustomWorldAgentWorkspace, - }; -}); - -export type SelectionStage = - | 'platform' - | 'detail' - | 'agent-workspace' - | 'custom-world-generating' - | 'custom-world-result'; - -type CustomWorldGenerationViewSource = 'agent-draft-foundation' | null; - -type CustomWorldResultViewSource = 'saved-profile' | 'agent-draft' | null; -type CustomWorldAutoSaveState = 'idle' | 'saving' | 'saved' | 'error'; -type SyncedAgentDraftResult = { - session: CustomWorldAgentSessionSnapshot | null; - profile: CustomWorldProfile | null; -}; - -type PreGameSelectionFlowProps = { - selectionStage: SelectionStage; - setSelectionStage: (stage: SelectionStage) => void; - gameState: GameState; - hasSavedGame: boolean; - savedSnapshot: HydratedSavedGameSnapshot | null; - handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void; - handleStartNewGame: () => void; - handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void; -}; - -function resolveErrorMessage(error: unknown, fallback: string) { - return error instanceof Error ? error.message : fallback; -} - -function createFailedAgentOperation(params: { - type: CustomWorldAgentOperationRecord['type']; - phaseLabel: string; - error: string; -}): CustomWorldAgentOperationRecord { - return { - operationId: `local-failed-${Date.now()}`, - type: params.type, - status: 'failed', - phaseLabel: params.phaseLabel, - phaseDetail: params.error, - progress: 100, - error: params.error, - }; -} - -function buildOptimisticAgentMessage( - payload: Pick, -): CustomWorldAgentMessage { - return { - ...payload, - createdAt: new Date().toISOString(), - relatedOperationId: null, - }; -} - -function normalizeAgentBackedProfile(profile: CustomWorldProfile) { - const foundationText = buildCustomWorldCreatorIntentFoundationText( - profile.creatorIntent, - ).trim(); - - if (!foundationText || foundationText === profile.settingText.trim()) { - return profile; - } - - return { - ...profile, - settingText: foundationText, - } satisfies CustomWorldProfile; -} - -function stringifyAgentBackedProfile(profile: CustomWorldProfile) { - return JSON.stringify(normalizeAgentBackedProfile(profile)); -} - -function LazyPanelFallback({ label }: { label: string }) { - return ( -
-
- {label} -
-
- ); -} - -function buildCreationHubFallbackItems( - entries: CustomWorldLibraryEntry[], -): CustomWorldWorkSummary[] { - return entries - .filter((entry) => entry.visibility === 'published') - .map((entry) => ({ - workId: `fallback:${entry.profileId}`, - sourceType: 'published_profile', - status: 'published', - title: entry.worldName, - subtitle: entry.subtitle || '已发布作品', - summary: entry.summaryText || '继续补完这个世界的设定与游玩入口。', - coverImageSrc: entry.coverImageSrc, - coverRenderMode: 'image', - coverCharacterImageSrcs: [], - updatedAt: entry.updatedAt, - publishedAt: entry.publishedAt, - stage: null, - stageLabel: '已发布', - playableNpcCount: entry.playableNpcCount, - landmarkCount: entry.landmarkCount, - roleVisualReadyCount: 0, - roleAnimationReadyCount: 0, - roleAssetSummaryLabel: null, - sessionId: null, - profileId: entry.profileId, - canResume: false, - canEnterWorld: true, - })); -} - -export function PreGameSelectionFlow({ - selectionStage, - setSelectionStage, - hasSavedGame, - savedSnapshot, - handleContinueGame, - handleStartNewGame, - handleCustomWorldSelect, -}: PreGameSelectionFlowProps) { - const authUi = useAuthUi(); - const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState()); - const hasAppliedInitialAgentWorkspaceRef = useRef(false); - const hasRequestedInitialAgentWorkspaceAuthRef = useRef(false); - const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] = - useState(null); - const [savedCustomWorldEntries, setSavedCustomWorldEntries] = useState< - CustomWorldLibraryEntry[] - >([]); - const [customWorldWorkEntries, setCustomWorldWorkEntries] = useState< - CustomWorldWorkSummary[] - >([]); - const [publishedGalleryEntries, setPublishedGalleryEntries] = useState< - CustomWorldGalleryCard[] - >([]); - const [historyEntries, setHistoryEntries] = useState< - PlatformBrowseHistoryEntry[] - >([]); - const [saveEntries, setSaveEntries] = useState( - [], - ); - const [platformTab, setPlatformTab] = useState('home'); - const [selectedDetailEntry, setSelectedDetailEntry] = - useState | null>(null); - const [showCreationTypeModal, setShowCreationTypeModal] = useState(false); - const [creationTypeError, setCreationTypeError] = useState( - null, - ); - const [isCreatingAgentSession, setIsCreatingAgentSession] = useState(false); - const [activeAgentSessionId, setActiveAgentSessionId] = useState< - string | null - >(() => initialAgentUiStateRef.current.activeSessionId ?? null); - const [activeAgentOperationId, setActiveAgentOperationId] = useState< - string | null - >(() => initialAgentUiStateRef.current.activeOperationId ?? null); - const [agentSession, setAgentSession] = - useState(null); - const [agentOperation, setAgentOperation] = - useState(null); - const [streamingAgentReplyText, setStreamingAgentReplyText] = useState(''); - const [isStreamingAgentReply, setIsStreamingAgentReply] = useState(false); - const [isLoadingAgentSession, setIsLoadingAgentSession] = useState(false); - const [customWorldError, setCustomWorldError] = useState(null); - const [platformError, setPlatformError] = useState(null); - const [profileDashboard, setProfileDashboard] = - useState(null); - const [dashboardError, setDashboardError] = useState(null); - const [_historyError, setHistoryError] = useState(null); - const [saveError, setSaveError] = useState(null); - const [detailError, setDetailError] = useState(null); - const [isLoadingPlatform, setIsLoadingPlatform] = useState(false); - const [isLoadingDashboard, setIsLoadingDashboard] = useState(false); - const [isResumingSaveWorldKey, setIsResumingSaveWorldKey] = useState< - string | null - >(null); - const [isDetailLoading, setIsDetailLoading] = useState(false); - const [isMutatingDetail, setIsMutatingDetail] = useState(false); - const [customWorldAutoSaveState, setCustomWorldAutoSaveState] = - useState('idle'); - const [customWorldAutoSaveError, setCustomWorldAutoSaveError] = useState< - string | null - >(null); - const [customWorldGenerationViewSource, setCustomWorldGenerationViewSource] = - useState(null); - const [customWorldResultViewSource, setCustomWorldResultViewSource] = - useState(null); - const [agentDraftGenerationStartedAt, setAgentDraftGenerationStartedAt] = - useState(null); - const customWorldAutoSaveTimeoutRef = useRef(null); - const lastAutoSavedProfileSignatureRef = useRef(null); - const latestAutoSaveRequestIdRef = useRef(0); - const latestAgentResultSyncSignatureRef = useRef(null); - // 用户手动返回工作区后,先抑制自动重开结果页,避免刚退出又被 session 快照顶回去。 - const isAgentDraftResultAutoOpenSuppressedRef = useRef(false); - const isCustomWorldAutoSaveBusyRef = useRef(false); - const platformTabBootstrapUserIdRef = useRef( - undefined, - ); - - const previewCustomWorldCharacters = useMemo( - () => - generatedCustomWorldProfile - ? buildCustomWorldPlayableCharacters(generatedCustomWorldProfile) - : [], - [generatedCustomWorldProfile], - ); - - const featuredGalleryEntries = useMemo( - () => publishedGalleryEntries.slice(0, 6), - [publishedGalleryEntries], - ); - const isAuthenticated = Boolean(authUi?.user); - - const runProtectedAction = useCallback( - (action: () => void) => { - if (!authUi?.requireAuth) { - action(); - return; - } - - authUi.requireAuth(action); - }, - [authUi], - ); - - const persistAgentUiState = useCallback( - (nextSessionId: string | null, nextOperationId: string | null) => { - setActiveAgentSessionId(nextSessionId); - setActiveAgentOperationId(nextOperationId); - writeCustomWorldAgentUiState({ - activeSessionId: nextSessionId, - activeOperationId: nextOperationId, - }); - }, - [], - ); - - const syncAgentSessionSnapshot = useCallback(async (sessionId: string) => { - const nextSession = await getCustomWorldAgentSession(sessionId); - setAgentSession(nextSession); - return nextSession; - }, []); - - const refreshProfileDashboard = useCallback(async () => { - if (!authUi?.user) { - setProfileDashboard(null); - setDashboardError(null); - setIsLoadingDashboard(false); - return; - } - - setIsLoadingDashboard(true); - setDashboardError(null); - - try { - setProfileDashboard(await getProfileDashboard()); - } catch (error) { - setDashboardError(resolveErrorMessage(error, '读取个人数据看板失败。')); - } finally { - setIsLoadingDashboard(false); - } - }, [authUi?.user]); - - const refreshCustomWorldWorks = useCallback(async () => { - if (!authUi?.user) { - setCustomWorldWorkEntries([]); - return []; - } - - const nextItems = await listCustomWorldWorks(); - setCustomWorldWorkEntries(nextItems); - return nextItems; - }, [authUi?.user]); - - const appendBrowseHistoryEntry = useCallback( - async (entry: PlatformBrowseHistoryWriteEntry) => { - setHistoryError(null); - - try { - const syncedEntries = await upsertProfileBrowseHistory(entry); - setHistoryEntries(syncedEntries); - } catch (error) { - setHistoryError(resolveErrorMessage(error, '写入浏览历史失败。')); - } - }, - [authUi?.user], - ); - - useEffect(() => { - const initialAgentSessionId = initialAgentUiStateRef.current.activeSessionId; - - if (!initialAgentSessionId || hasAppliedInitialAgentWorkspaceRef.current) { - return; - } - - setPlatformTab('create'); - - // URL 或 sessionStorage 中残留的共创工作区属于受保护入口, - // 未登录时只允许先唤起登录弹窗,不能直接恢复会话请求。 - if (!authUi?.user) { - if (!hasRequestedInitialAgentWorkspaceAuthRef.current) { - hasRequestedInitialAgentWorkspaceAuthRef.current = true; - authUi?.openLoginModal?.(() => { - setSelectionStage('agent-workspace'); - }); - } - return; - } - - hasAppliedInitialAgentWorkspaceRef.current = true; - setSelectionStage('agent-workspace'); - }, [authUi?.openLoginModal, authUi?.user, setSelectionStage]); - - useEffect(() => { - if (!selectedDetailEntry) { - return; - } - - const nextOwnedEntry = savedCustomWorldEntries.find( - (entry) => - entry.ownerUserId === selectedDetailEntry.ownerUserId && - entry.profileId === selectedDetailEntry.profileId, - ); - if (nextOwnedEntry && nextOwnedEntry !== selectedDetailEntry) { - setSelectedDetailEntry(nextOwnedEntry); - } - }, [savedCustomWorldEntries, selectedDetailEntry]); - - useEffect(() => { - let isActive = true; - - void (async () => { - setHistoryEntries([]); - setHistoryError(null); - setSaveError(null); - setIsLoadingPlatform(true); - setPlatformError(null); - setIsLoadingDashboard(isAuthenticated); - setDashboardError(null); - if (!isAuthenticated) { - setSavedCustomWorldEntries([]); - setCustomWorldWorkEntries([]); - setSaveEntries([]); - setProfileDashboard(null); - } - - try { - const [ - libraryEntriesResult, - workEntriesResult, - galleryEntriesResult, - dashboardResult, - historyResult, - saveArchivesResult, - ] = await Promise.allSettled([ - isAuthenticated ? listCustomWorldLibrary() : Promise.resolve([]), - isAuthenticated ? listCustomWorldWorks() : Promise.resolve([]), - listCustomWorldGallery(), - isAuthenticated ? getProfileDashboard() : Promise.resolve(null), - isAuthenticated ? listProfileBrowseHistory() : Promise.resolve([]), - isAuthenticated ? listProfileSaveArchives() : Promise.resolve([]), - ]); - if (!isActive) { - return; - } - - if (libraryEntriesResult.status === 'fulfilled') { - setSavedCustomWorldEntries(libraryEntriesResult.value); - } else { - setSavedCustomWorldEntries([]); - } - - if (workEntriesResult.status === 'fulfilled') { - setCustomWorldWorkEntries(workEntriesResult.value); - } else { - setCustomWorldWorkEntries([]); - } - - if (galleryEntriesResult.status === 'fulfilled') { - setPublishedGalleryEntries(galleryEntriesResult.value); - } else { - setPublishedGalleryEntries([]); - } - - if ( - (isAuthenticated && libraryEntriesResult.status === 'rejected') || - (isAuthenticated && workEntriesResult.status === 'rejected') || - galleryEntriesResult.status === 'rejected' - ) { - const platformFailure = - libraryEntriesResult.status === 'rejected' - ? libraryEntriesResult.reason - : workEntriesResult.status === 'rejected' - ? workEntriesResult.reason - : galleryEntriesResult.status === 'rejected' - ? galleryEntriesResult.reason - : null; - setPlatformError( - resolveErrorMessage(platformFailure, '读取平台数据失败。'), - ); - } - - if (dashboardResult.status === 'fulfilled') { - setProfileDashboard(dashboardResult.value); - } else if (isAuthenticated) { - setProfileDashboard(null); - setDashboardError( - resolveErrorMessage( - dashboardResult.reason, - '读取个人数据看板失败。', - ), - ); - } - - if (historyResult.status === 'fulfilled') { - setHistoryEntries(historyResult.value); - } else if (isAuthenticated) { - setHistoryError( - resolveErrorMessage(historyResult.reason, '读取浏览历史失败。'), - ); - } - - if (saveArchivesResult.status === 'fulfilled') { - setSaveEntries(saveArchivesResult.value); - } else if (isAuthenticated) { - setSaveEntries([]); - setSaveError( - resolveErrorMessage( - saveArchivesResult.reason, - '读取存档列表失败。', - ), - ); - } - - const nextPlatformBootstrapUserId = authUi?.user?.id ?? null; - if ( - platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId - ) { - platformTabBootstrapUserIdRef.current = nextPlatformBootstrapUserId; - if (!initialAgentUiStateRef.current.activeSessionId) { - setPlatformTab( - isAuthenticated && - saveArchivesResult.status === 'fulfilled' && - saveArchivesResult.value.length > 0 - ? 'saves' - : 'home', - ); - } - } - } finally { - if (isActive) { - setIsLoadingPlatform(false); - setIsLoadingDashboard(false); - } - } - })(); - - return () => { - isActive = false; - }; - }, [authUi?.user, isAuthenticated]); - - useEffect(() => { - if ( - selectionStage === 'custom-world-result' && - !generatedCustomWorldProfile - ) { - setSelectionStage(selectedDetailEntry ? 'detail' : 'platform'); - } - }, [ - generatedCustomWorldProfile, - selectedDetailEntry, - selectionStage, - setSelectionStage, - ]); - - useEffect( - () => () => { - if (customWorldAutoSaveTimeoutRef.current !== null) { - window.clearTimeout(customWorldAutoSaveTimeoutRef.current); - } - }, - [], - ); - - useEffect(() => { - if (!activeAgentSessionId) { - setAgentSession(null); - setAgentOperation(null); - setIsLoadingAgentSession(false); - setStreamingAgentReplyText(''); - setIsStreamingAgentReply(false); - return; - } - - if (!authUi?.user) { - setAgentSession(null); - setAgentOperation(null); - setIsLoadingAgentSession(false); - setStreamingAgentReplyText(''); - setIsStreamingAgentReply(false); - return; - } - - let cancelled = false; - setIsLoadingAgentSession(true); - - void syncAgentSessionSnapshot(activeAgentSessionId) - .then(() => { - if (!cancelled) { - setCreationTypeError(null); - } - }) - .catch((error) => { - if (cancelled) { - return; - } - - setCreationTypeError( - resolveErrorMessage(error, '读取 Agent 共创工作区失败。'), - ); - setAgentSession(null); - setAgentOperation(null); - setStreamingAgentReplyText(''); - setIsStreamingAgentReply(false); - persistAgentUiState(null, null); - setPlatformTab('create'); - setSelectionStage('platform'); - }) - .finally(() => { - if (!cancelled) { - setIsLoadingAgentSession(false); - } - }); - - return () => { - cancelled = true; - }; - }, [ - activeAgentSessionId, - authUi?.user, - persistAgentUiState, - setSelectionStage, - syncAgentSessionSnapshot, - ]); - - useEffect(() => { - if (!activeAgentSessionId || !activeAgentOperationId || !authUi?.user) { - return; - } - - let cancelled = false; - - const pollOperation = async () => { - try { - const nextOperation = await getCustomWorldAgentOperation( - activeAgentSessionId, - activeAgentOperationId, - ); - - if (cancelled) { - return; - } - - setAgentOperation(nextOperation); - - if ( - nextOperation.status === 'completed' || - nextOperation.status === 'failed' - ) { - persistAgentUiState(activeAgentSessionId, null); - await syncAgentSessionSnapshot(activeAgentSessionId).catch( - () => null, - ); - } - } catch (error) { - if (cancelled) { - return; - } - - const errorMessage = resolveErrorMessage( - error, - '读取共创操作状态失败。', - ); - setAgentOperation( - createFailedAgentOperation({ - type: 'process_message', - phaseLabel: '读取操作状态失败', - error: errorMessage, - }), - ); - persistAgentUiState(activeAgentSessionId, null); - } - }; - - void pollOperation(); - const intervalId = window.setInterval(() => { - void pollOperation(); - }, 1200); - - return () => { - cancelled = true; - window.clearInterval(intervalId); - }; - }, [ - activeAgentOperationId, - activeAgentSessionId, - authUi?.user, - persistAgentUiState, - syncAgentSessionSnapshot, - ]); - - useEffect(() => { - if ( - !isDraftFoundationOperationRunning(agentOperation) || - agentDraftGenerationStartedAt - ) { - return; - } - - setAgentDraftGenerationStartedAt(Date.now()); - }, [agentDraftGenerationStartedAt, agentOperation]); - - useEffect(() => { - if ( - selectionStage !== 'custom-world-generating' || - customWorldGenerationViewSource !== 'agent-draft-foundation' || - !isDraftFoundationOperation(agentOperation) || - agentOperation.status !== 'completed' - ) { - return; - } - - let cancelled = false; - const timeoutId = window.setTimeout(() => { - void (async () => { - const latestSession = activeAgentSessionId - ? await syncAgentSessionSnapshot(activeAgentSessionId).catch( - () => null, - ) - : agentSession; - - if (cancelled) { - return; - } - - const draftResultProfile = buildCustomWorldProfileFromAgentDraft( - latestSession ?? agentSession, - ); - if (!draftResultProfile) { - setAgentDraftGenerationStartedAt(null); - setCustomWorldGenerationViewSource(null); - setSelectionStage('agent-workspace'); - return; - } - - setGeneratedCustomWorldProfile( - normalizeAgentBackedProfile(draftResultProfile), - ); - setAgentDraftGenerationStartedAt(null); - setCustomWorldGenerationViewSource(null); - setCustomWorldResultViewSource('agent-draft'); - setSelectionStage('custom-world-result'); - })(); - }, 900); - - return () => { - cancelled = true; - window.clearTimeout(timeoutId); - }; - }, [ - activeAgentSessionId, - agentOperation, - customWorldGenerationViewSource, - agentSession, - selectionStage, - setSelectionStage, - syncAgentSessionSnapshot, - ]); - - const agentDraftSettingPreview = useMemo( - () => buildAgentDraftFoundationSettingText(agentSession), - [agentSession], - ); - const agentDraftAnchorPreviewEntries = useMemo( - () => buildAgentDraftFoundationAnchorEntries(agentSession), - [agentSession], - ); - const agentDraftResultProfile = useMemo( - () => buildCustomWorldProfileFromAgentDraft(agentSession), - [agentSession], - ); - const shouldAutoOpenAgentDraftResult = useMemo( - () => - Boolean( - agentDraftResultProfile && - agentSession && - (agentSession.stage === 'object_refining' || - agentSession.stage === 'visual_refining' || - agentSession.stage === 'long_tail_review' || - agentSession.stage === 'ready_to_publish' || - agentSession.stage === 'published') && - agentSession.draftCards.length > 0, - ), - [agentDraftResultProfile, agentSession], - ); - - useEffect(() => { - if (!shouldAutoOpenAgentDraftResult || !agentDraftResultProfile) { - return; - } - - if (isAgentDraftResultAutoOpenSuppressedRef.current) { - return; - } - - if (selectionStage === 'agent-workspace') { - setGeneratedCustomWorldProfile(agentDraftResultProfile); - setCustomWorldResultViewSource('agent-draft'); - isAgentDraftResultAutoOpenSuppressedRef.current = false; - setSelectionStage('custom-world-result'); - return; - } - - if ( - selectionStage === 'custom-world-result' && - !generatedCustomWorldProfile - ) { - setGeneratedCustomWorldProfile(agentDraftResultProfile); - setCustomWorldResultViewSource('agent-draft'); - isAgentDraftResultAutoOpenSuppressedRef.current = false; - } - }, [ - agentDraftResultProfile, - generatedCustomWorldProfile, - isAgentDraftResultAutoOpenSuppressedRef, - selectionStage, - setSelectionStage, - shouldAutoOpenAgentDraftResult, - ]); - - const agentDraftGenerationProgress = useMemo( - () => - buildAgentDraftFoundationGenerationProgress( - agentOperation, - agentDraftGenerationStartedAt, - ), - [agentDraftGenerationStartedAt, agentOperation], - ); - - const isAgentDraftGenerationView = - customWorldGenerationViewSource === 'agent-draft-foundation'; - const isAgentDraftResultView = customWorldResultViewSource === 'agent-draft'; - const activeGenerationSettingText = agentDraftSettingPreview; - const activeGenerationProgress = agentDraftGenerationProgress; - const isActiveGenerationRunning = - isDraftFoundationOperationRunning(agentOperation); - const activeGenerationError = - isDraftFoundationOperation(agentOperation) && - agentOperation.status === 'failed' - ? agentOperation.error || agentOperation.phaseDetail - : null; - - const leaveCustomWorldResult = () => { - setGeneratedCustomWorldProfile(null); - setCustomWorldError(null); - setCustomWorldAutoSaveError(null); - setCustomWorldAutoSaveState('idle'); - setCustomWorldGenerationViewSource(null); - setCustomWorldResultViewSource(null); - setSelectionStage( - isAgentDraftResultView - ? 'agent-workspace' - : selectedDetailEntry - ? 'detail' - : 'platform', - ); - }; - - const openCreationTypePicker = () => { - if (isCreatingAgentSession) { - return; - } - - if (!hasSavedGame) { - handleStartNewGame(); - } - - setCreationTypeError(null); - setShowCreationTypeModal(true); - }; - - const openRpgAgentWorkspace = async (seedText = '') => { - if (isCreatingAgentSession) { - return; - } - - setIsCreatingAgentSession(true); - setCreationTypeError(null); - isAgentDraftResultAutoOpenSuppressedRef.current = false; - - try { - const { session } = await createCustomWorldAgentSession( - seedText ? { seedText } : {}, - ); - setAgentSession(session); - setAgentOperation(null); - setGeneratedCustomWorldProfile(null); - setCustomWorldAutoSaveError(null); - setCustomWorldAutoSaveState('idle'); - setAgentDraftGenerationStartedAt(null); - setCustomWorldGenerationViewSource(null); - setCustomWorldResultViewSource(null); - persistAgentUiState(session.sessionId, null); - setShowCreationTypeModal(false); - setPlatformTab('create'); - setSelectionStage('agent-workspace'); - } catch (error) { - setCreationTypeError(resolveErrorMessage(error, '开启共创工作台失败。')); - } finally { - setIsCreatingAgentSession(false); - } - }; - - const submitAgentMessage = async ( - payload: SendCustomWorldAgentMessageRequest, - ) => { - if (!activeAgentSessionId) { - return; - } - - const optimisticUserMessage = buildOptimisticAgentMessage({ - id: payload.clientMessageId, - role: 'user', - kind: 'chat', - text: payload.text.trim(), - }); - - setAgentOperation(null); - persistAgentUiState(activeAgentSessionId, null); - setStreamingAgentReplyText(''); - setIsStreamingAgentReply(true); - setAgentSession((current) => - current - ? { - ...current, - messages: [...current.messages, optimisticUserMessage], - updatedAt: optimisticUserMessage.createdAt, - } - : current, - ); - - try { - const nextSession = await streamCustomWorldAgentMessage( - activeAgentSessionId, - payload, - { - onUpdate: (text) => { - setStreamingAgentReplyText(text); - }, - }, - ); - setAgentSession(nextSession); - setAgentOperation(null); - setStreamingAgentReplyText(''); - } catch (error) { - const errorMessage = resolveErrorMessage(error, '发送共创消息失败。'); - setAgentSession((current) => - current - ? { - ...current, - messages: [ - ...current.messages, - buildOptimisticAgentMessage({ - id: `message-error-${Date.now()}`, - role: 'assistant', - kind: 'warning', - text: errorMessage, - }), - ], - updatedAt: new Date().toISOString(), - } - : current, - ); - setStreamingAgentReplyText(''); - persistAgentUiState(activeAgentSessionId, null); - } finally { - setIsStreamingAgentReply(false); - } - }; - - const executeAgentAction = async (payload: CustomWorldAgentActionRequest) => { - if (!activeAgentSessionId) { - return; - } - - const isDraftFoundationAction = payload.action === 'draft_foundation'; - - if (isDraftFoundationAction) { - isAgentDraftResultAutoOpenSuppressedRef.current = false; - setGeneratedCustomWorldProfile(null); - setCustomWorldError(null); - setCustomWorldAutoSaveError(null); - setCustomWorldAutoSaveState('idle'); - setCustomWorldGenerationViewSource('agent-draft-foundation'); - setCustomWorldResultViewSource(null); - setAgentDraftGenerationStartedAt(Date.now()); - setSelectionStage('custom-world-generating'); - } - - try { - const { operation } = await executeCustomWorldAgentAction( - activeAgentSessionId, - payload, - ); - setAgentOperation(operation); - persistAgentUiState(activeAgentSessionId, operation.operationId); - } catch (error) { - const errorMessage = resolveErrorMessage(error, '执行共创操作失败。'); - setAgentOperation( - createFailedAgentOperation({ - type: - payload.action === 'draft_foundation' - ? 'draft_foundation' - : payload.action, - phaseLabel: '执行操作失败', - error: errorMessage, - }), - ); - persistAgentUiState(activeAgentSessionId, null); - } - }; - - const leaveAgentWorkspace = () => { - setPlatformTab('create'); - setAgentOperation(null); - setStreamingAgentReplyText(''); - setIsStreamingAgentReply(false); - setGeneratedCustomWorldProfile(null); - setCustomWorldAutoSaveError(null); - setCustomWorldAutoSaveState('idle'); - setAgentDraftGenerationStartedAt(null); - setCustomWorldGenerationViewSource(null); - setCustomWorldResultViewSource(null); - persistAgentUiState(activeAgentSessionId, null); - setSelectionStage('platform'); - }; - - const leaveAgentDraftGeneration = () => { - if (isDraftFoundationOperationRunning(agentOperation)) { - return; - } - - setAgentDraftGenerationStartedAt(null); - setCustomWorldGenerationViewSource(null); - setSelectionStage('agent-workspace'); - }; - - const leaveAgentDraftResult = () => { - isAgentDraftResultAutoOpenSuppressedRef.current = true; - setGeneratedCustomWorldProfile(null); - setCustomWorldError(null); - setCustomWorldAutoSaveError(null); - setCustomWorldAutoSaveState('idle'); - setCustomWorldGenerationViewSource(null); - setCustomWorldResultViewSource(null); - setPlatformTab('create'); - setSelectionStage('platform'); - }; - - const retryAgentDraftGeneration = () => { - void executeAgentAction({ - action: 'draft_foundation', - }); - }; - - const openCustomWorldCreator = () => { - openCreationTypePicker(); - }; - - const openLibraryDetail = useCallback( - (entry: CustomWorldLibraryEntry) => { - if (entry.visibility === 'published') { - void appendBrowseHistoryEntry({ - ownerUserId: entry.ownerUserId, - profileId: entry.profileId, - worldName: entry.worldName, - subtitle: entry.subtitle, - summaryText: entry.summaryText, - coverImageSrc: entry.coverImageSrc, - themeMode: entry.themeMode, - authorDisplayName: entry.authorDisplayName, - }); - } - setSelectedDetailEntry(entry); - setDetailError(null); - setSelectionStage('detail'); - }, - [appendBrowseHistoryEntry, setSelectionStage], - ); - - const handleOpenCreationWork = useCallback( - async (work: CustomWorldWorkSummary) => { - if (work.status === 'draft' && work.sessionId) { - persistAgentUiState(work.sessionId, null); - setCustomWorldError(null); - setCustomWorldAutoSaveError(null); - setCustomWorldAutoSaveState('idle'); - setCustomWorldGenerationViewSource(null); - - const shouldOpenAgentWorkspace = - work.playableNpcCount <= 0 && work.landmarkCount <= 0; - - if (shouldOpenAgentWorkspace) { - // 仅八锚点未整理成底稿时才恢复 Agent 对话工作区。 - isAgentDraftResultAutoOpenSuppressedRef.current = true; - setGeneratedCustomWorldProfile(null); - setCustomWorldResultViewSource(null); - setPlatformTab('create'); - setSelectionStage('agent-workspace'); - return; - } - - isAgentDraftResultAutoOpenSuppressedRef.current = false; - const latestSession = await syncAgentSessionSnapshot(work.sessionId); - const nextProfile = buildCustomWorldProfileFromAgentDraft(latestSession); - if (!nextProfile) { - setPlatformError('当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。'); - setPlatformTab('create'); - setSelectionStage('agent-workspace'); - return; - } - - setGeneratedCustomWorldProfile(normalizeAgentBackedProfile(nextProfile)); - setCustomWorldResultViewSource('agent-draft'); - setPlatformTab('create'); - setSelectionStage('custom-world-result'); - return; - } - - if (!work.profileId) { - return; - } - - try { - let matchedEntry = savedCustomWorldEntries.find( - (entry) => entry.profileId === work.profileId, - ); - - if (!matchedEntry && authUi?.user) { - const latestLibraryEntries = await listCustomWorldLibrary(); - setSavedCustomWorldEntries(latestLibraryEntries); - matchedEntry = latestLibraryEntries.find( - (entry) => entry.profileId === work.profileId, - ); - } - - if (matchedEntry) { - openLibraryDetail(matchedEntry); - return; - } - - setPlatformError('未找到对应作品,请刷新后重试。'); - } catch (error) { - setPlatformError(resolveErrorMessage(error, '读取作品详情失败。')); - } - }, - [ - authUi?.user, - openLibraryDetail, - persistAgentUiState, - savedCustomWorldEntries, - syncAgentSessionSnapshot, - setSelectionStage, - ], - ); - - const openGalleryDetail = async (entry: CustomWorldGalleryCard) => { - setSelectionStage('detail'); - setIsDetailLoading(true); - setDetailError(null); - try { - const detailEntry = await getCustomWorldGalleryDetail( - entry.ownerUserId, - entry.profileId, - ); - setSelectedDetailEntry(detailEntry); - void appendBrowseHistoryEntry({ - ownerUserId: detailEntry.ownerUserId, - profileId: detailEntry.profileId, - worldName: detailEntry.worldName, - subtitle: detailEntry.subtitle, - summaryText: detailEntry.summaryText, - coverImageSrc: detailEntry.coverImageSrc, - themeMode: detailEntry.themeMode, - authorDisplayName: detailEntry.authorDisplayName, - }); - } catch (error) { - setSelectedDetailEntry(null); - setDetailError(resolveErrorMessage(error, '读取作品详情失败。')); - } finally { - setIsDetailLoading(false); - } - }; - - const handleResumeSaveEntry = useCallback( - async (entry: ProfileSaveArchiveSummary) => { - if (!authUi?.user || isResumingSaveWorldKey) { - return; - } - - setIsResumingSaveWorldKey(entry.worldKey); - setSaveError(null); - - try { - const resumedArchive = await resumeProfileSaveArchive(entry.worldKey); - setSaveEntries((currentEntries) => - currentEntries.map((currentEntry) => - currentEntry.worldKey === resumedArchive.entry.worldKey - ? resumedArchive.entry - : currentEntry, - ), - ); - handleContinueGame(resumedArchive.snapshot); - } catch (error) { - setSaveError(resolveErrorMessage(error, '恢复存档失败。')); - } finally { - setIsResumingSaveWorldKey(null); - } - }, - [authUi?.user, handleContinueGame, isResumingSaveWorldKey], - ); - - const saveGeneratedCustomWorld = useCallback( - async (profile = generatedCustomWorldProfile) => { - if (!profile) { - return null; - } - - const normalizedProfile = normalizeAgentBackedProfile(profile); - const profileSignature = stringifyAgentBackedProfile(normalizedProfile); - const requestId = latestAutoSaveRequestIdRef.current + 1; - latestAutoSaveRequestIdRef.current = requestId; - setCustomWorldAutoSaveState('saving'); - setCustomWorldAutoSaveError(null); - - try { - const mutation = await upsertCustomWorldProfile(normalizedProfile); - if (latestAutoSaveRequestIdRef.current !== requestId) { - return mutation; - } - - lastAutoSavedProfileSignatureRef.current = profileSignature; - setSavedCustomWorldEntries(mutation.entries); - if (authUi?.user) { - void refreshCustomWorldWorks().catch(() => {}); - } - setSelectedDetailEntry((current) => { - if (!current || current.profileId === mutation.entry.profileId) { - return mutation.entry; - } - - return current; - }); - setCustomWorldAutoSaveState('saved'); - setCustomWorldAutoSaveError(null); - return mutation; - } catch (error) { - if (latestAutoSaveRequestIdRef.current !== requestId) { - return null; - } - - setCustomWorldAutoSaveState('error'); - setCustomWorldAutoSaveError( - resolveErrorMessage(error, '保存自定义世界失败。'), - ); - return null; - } - }, - [authUi?.user, generatedCustomWorldProfile, refreshCustomWorldWorks], - ); - - const syncAgentDraftResultProfile = useCallback( - async (profile: CustomWorldProfile) => { - if (!activeAgentSessionId) { - return { - session: null, - profile: null, - } satisfies SyncedAgentDraftResult; - } - - const normalizedProfile = normalizeAgentBackedProfile(profile); - const profileSignature = stringifyAgentBackedProfile(normalizedProfile); - const latestSessionProfileSignature = - agentSession && buildCustomWorldProfileFromAgentDraft(agentSession) - ? stringifyAgentBackedProfile( - buildCustomWorldProfileFromAgentDraft(agentSession)!, - ) - : ''; - if (latestSessionProfileSignature === profileSignature) { - latestAgentResultSyncSignatureRef.current = profileSignature; - return { - session: agentSession, - profile: normalizeAgentBackedProfile( - buildCustomWorldProfileFromAgentDraft(agentSession) ?? profile, - ), - } satisfies SyncedAgentDraftResult; - } - if (latestAgentResultSyncSignatureRef.current === profileSignature) { - return { - session: agentSession, - profile: normalizeAgentBackedProfile( - buildCustomWorldProfileFromAgentDraft(agentSession) ?? profile, - ), - } satisfies SyncedAgentDraftResult; - } - - const { operation } = await executeCustomWorldAgentAction( - activeAgentSessionId, - { - action: 'sync_result_profile', - profile: normalizedProfile as unknown as Record, - }, - ); - setAgentOperation(operation); - persistAgentUiState(activeAgentSessionId, operation.operationId); - - for (let attempt = 0; attempt < 60; attempt += 1) { - const latestOperation = await getCustomWorldAgentOperation( - activeAgentSessionId, - operation.operationId, - ); - setAgentOperation(latestOperation); - - if (latestOperation.status === 'failed') { - throw new Error( - latestOperation.error || - latestOperation.phaseDetail || - '同步结果页世界快照失败。', - ); - } - - if (latestOperation.status === 'completed') { - persistAgentUiState(activeAgentSessionId, null); - const latestSession = await syncAgentSessionSnapshot( - activeAgentSessionId, - ); - // 同步完成后统一从最新 session 重编译结果,保证结果页、作品库和进入世界吃同一份快照。 - const latestProfile = normalizeAgentBackedProfile( - buildCustomWorldProfileFromAgentDraft(latestSession) ?? profile, - ); - if (latestProfile) { - setGeneratedCustomWorldProfile(latestProfile); - } - latestAgentResultSyncSignatureRef.current = profileSignature; - return { - session: latestSession, - profile: latestProfile, - } satisfies SyncedAgentDraftResult; - } - - await new Promise((resolve) => window.setTimeout(resolve, 200)); - } - - throw new Error('同步结果页世界快照超时。'); - }, - [ - activeAgentSessionId, - agentSession, - persistAgentUiState, - syncAgentSessionSnapshot, - ], - ); - - useEffect(() => { - if (!generatedCustomWorldProfile) { - setCustomWorldAutoSaveState('idle'); - setCustomWorldAutoSaveError(null); - lastAutoSavedProfileSignatureRef.current = null; - latestAgentResultSyncSignatureRef.current = null; - if (customWorldAutoSaveTimeoutRef.current !== null) { - window.clearTimeout(customWorldAutoSaveTimeoutRef.current); - customWorldAutoSaveTimeoutRef.current = null; - } - return; - } - - if (selectionStage !== 'custom-world-result') { - return; - } - - if (isCustomWorldAutoSaveBusyRef.current) { - return; - } - - const nextSignature = stringifyAgentBackedProfile(generatedCustomWorldProfile); - if (nextSignature === lastAutoSavedProfileSignatureRef.current) { - return; - } - - setCustomWorldAutoSaveState('saving'); - if (customWorldAutoSaveTimeoutRef.current !== null) { - window.clearTimeout(customWorldAutoSaveTimeoutRef.current); - } - - const profileToSave = generatedCustomWorldProfile; - customWorldAutoSaveTimeoutRef.current = window.setTimeout(() => { - void (async () => { - isCustomWorldAutoSaveBusyRef.current = true; - try { - let latestProfileToSave = normalizeAgentBackedProfile(profileToSave); - if (isAgentDraftResultView) { - const syncedResult = - await syncAgentDraftResultProfile(profileToSave); - // 作品库自动保存优先落同步后 session 重编译出的结果,避免继续保存旧的前端内存态。 - latestProfileToSave = normalizeAgentBackedProfile( - syncedResult.profile ?? profileToSave, - ); - } - await saveGeneratedCustomWorld(latestProfileToSave); - } catch (error) { - setCustomWorldAutoSaveState('error'); - setCustomWorldAutoSaveError( - resolveErrorMessage(error, '保存自定义世界失败。'), - ); - } finally { - isCustomWorldAutoSaveBusyRef.current = false; - } - })(); - customWorldAutoSaveTimeoutRef.current = null; - }, 600); - - return () => { - if (customWorldAutoSaveTimeoutRef.current !== null) { - window.clearTimeout(customWorldAutoSaveTimeoutRef.current); - customWorldAutoSaveTimeoutRef.current = null; - } - }; - }, [ - generatedCustomWorldProfile, - isAgentDraftResultView, - saveGeneratedCustomWorld, - selectionStage, - syncAgentDraftResultProfile, - ]); - - const openSavedCustomWorldEditor = ( - entry: CustomWorldLibraryEntry, - ) => { - setSelectedDetailEntry(entry); - const normalizedProfile = normalizeAgentBackedProfile(entry.profile); - setGeneratedCustomWorldProfile(normalizedProfile); - lastAutoSavedProfileSignatureRef.current = - JSON.stringify(normalizedProfile); - setCustomWorldAutoSaveState('saved'); - setCustomWorldAutoSaveError(null); - setCustomWorldError(null); - setCustomWorldGenerationViewSource(null); - setCustomWorldResultViewSource('saved-profile'); - setSelectionStage('custom-world-result'); - }; - - const handleStartSelectedWorld = () => { - if (!selectedDetailEntry) { - return; - } - - runProtectedAction(() => { - handleCustomWorldSelect(selectedDetailEntry.profile); - }); - }; - - const handlePublishSelectedWorld = async () => { - if (!selectedDetailEntry || isMutatingDetail) { - return; - } - - setIsMutatingDetail(true); - setDetailError(null); - try { - const mutation = await publishCustomWorldProfile( - selectedDetailEntry.profileId, - ); - setSavedCustomWorldEntries(mutation.entries); - await refreshCustomWorldWorks().catch(() => []); - setSelectedDetailEntry(mutation.entry); - setPublishedGalleryEntries(await listCustomWorldGallery()); - } catch (error) { - setDetailError(resolveErrorMessage(error, '发布自定义世界失败。')); - } finally { - setIsMutatingDetail(false); - } - }; - - const handleUnpublishSelectedWorld = async () => { - if (!selectedDetailEntry || isMutatingDetail) { - return; - } - - setIsMutatingDetail(true); - setDetailError(null); - try { - const mutation = await unpublishCustomWorldProfile( - selectedDetailEntry.profileId, - ); - setSavedCustomWorldEntries(mutation.entries); - await refreshCustomWorldWorks().catch(() => []); - setSelectedDetailEntry(mutation.entry); - setPublishedGalleryEntries(await listCustomWorldGallery()); - } catch (error) { - setDetailError(resolveErrorMessage(error, '下架自定义世界失败。')); - } finally { - setIsMutatingDetail(false); - } - }; - - const handleDeleteSelectedWorld = async () => { - if (!selectedDetailEntry || isMutatingDetail) { - return; - } - - const confirmed = window.confirm( - `确认删除作品《${selectedDetailEntry.worldName}》吗?删除后会从你的作品列表和公开广场中移除。`, - ); - if (!confirmed) { - return; - } - - setIsMutatingDetail(true); - setDetailError(null); - try { - const entries = await deleteCustomWorldProfile( - selectedDetailEntry.profileId, - ); - setSavedCustomWorldEntries(entries); - await refreshCustomWorldWorks().catch(() => []); - setSelectedDetailEntry(null); - setPlatformTab('create'); - setSelectionStage('platform'); - setPublishedGalleryEntries(await listCustomWorldGallery()); - } catch (error) { - setDetailError(resolveErrorMessage(error, '删除自定义世界失败。')); - } finally { - setIsMutatingDetail(false); - } - }; - - const isSelectedWorldOwned = Boolean( - selectedDetailEntry && - savedCustomWorldEntries.some( - (entry) => - entry.ownerUserId === selectedDetailEntry.ownerUserId && - entry.profileId === selectedDetailEntry.profileId, - ), - ); - const resultViewError = customWorldAutoSaveError ?? customWorldError; - const creationHubItems = - customWorldWorkEntries.length > 0 - ? customWorldWorkEntries - : buildCreationHubFallbackItems(savedCustomWorldEntries); - const creationHubContent = ( - { - setPlatformTab('home'); - }} - onRetry={() => { - setPlatformError(null); - void refreshCustomWorldWorks().catch((error) => { - setPlatformError( - resolveErrorMessage(error, '读取创作作品列表失败。'), - ); - }); - }} - onCreateNew={openCreationTypePicker} - onOpenDraft={(item) => { - runProtectedAction(() => { - void handleOpenCreationWork(item); - }); - }} - onEnterPublished={(profileId) => { - runProtectedAction(() => { - const matchedWork = creationHubItems.find( - (entry) => entry.profileId === profileId, - ); - if (!matchedWork) { - return; - } - void handleOpenCreationWork(matchedWork); - }); - }} - /> - ); - - return ( - <> - - {selectionStage === 'platform' && ( - - { - void handleResumeSaveEntry(entry); - }} - onOpenCreateWorld={openCustomWorldCreator} - onOpenCreateTypePicker={openCreationTypePicker} - onOpenGalleryDetail={(entry) => { - runProtectedAction(() => { - void openGalleryDetail(entry); - }); - }} - onOpenLibraryDetail={(entry) => { - runProtectedAction(() => { - openLibraryDetail(entry); - }); - }} - onOpenProfileDashboardCard={() => { - if (dashboardError) { - void refreshProfileDashboard(); - } - }} - /> - - )} - - {selectionStage === 'detail' && ( - - {isDetailLoading || !selectedDetailEntry ? ( -
-
- {detailError || '正在读取作品详情...'} -
-
- ) : ( - { - setDetailError(null); - setSelectionStage('platform'); - }} - onStartGame={handleStartSelectedWorld} - onContinueEdit={ - isSelectedWorldOwned - ? () => { - runProtectedAction(() => { - openSavedCustomWorldEditor(selectedDetailEntry); - }); - } - : null - } - onPublish={ - selectedDetailEntry.visibility === 'draft' && - isSelectedWorldOwned - ? () => { - runProtectedAction(() => { - void handlePublishSelectedWorld(); - }); - } - : null - } - onUnpublish={ - selectedDetailEntry.visibility === 'published' && - isSelectedWorldOwned - ? () => { - runProtectedAction(() => { - void handleUnpublishSelectedWorld(); - }); - } - : null - } - onDelete={ - isSelectedWorldOwned - ? () => { - runProtectedAction(() => { - void handleDeleteSelectedWorld(); - }); - } - : null - } - /> - )} -
- )} - - {selectionStage === 'agent-workspace' && ( - - - } - > - {agentSession ? ( - { - void submitAgentMessage(payload); - }} - onExecuteAction={(payload) => { - void executeAgentAction(payload); - }} - /> - ) : ( -
-
- {isLoadingAgentSession - ? '正在准备 Agent 共创工作区...' - : creationTypeError || '正在恢复创作工作区...'} -
-
- )} -
-
- )} - - {selectionStage === 'custom-world-generating' && ( - - } - > - - - - )} - - {selectionStage === 'custom-world-result' && - generatedCustomWorldProfile && ( - - } - > - { - setGeneratedCustomWorldProfile( - normalizeAgentBackedProfile(profile), - ); - }} - onBack={ - isAgentDraftResultView - ? () => { - void (async () => { - const currentProfile = - generatedCustomWorldProfile ?? - buildCustomWorldProfileFromAgentDraft( - agentSession, - ); - - if (currentProfile && activeAgentSessionId) { - await syncAgentDraftResultProfile(currentProfile); - } - - leaveAgentDraftResult(); - })().catch((error) => { - setCustomWorldError( - resolveErrorMessage( - error, - '返回创作前同步草稿失败。', - ), - ); - }); - } - : leaveCustomWorldResult - } - onEditSetting={undefined} - onRegenerate={undefined} - onContinueExpand={undefined} - onEnterWorld={() => { - runProtectedAction(() => { - void (async () => { - if (!isAgentDraftResultView || !activeAgentSessionId) { - handleCustomWorldSelect(generatedCustomWorldProfile); - return; - } - - const currentProfile = - generatedCustomWorldProfile ?? - buildCustomWorldProfileFromAgentDraft(agentSession); - if (!currentProfile) { - return; - } - - const latestResult = await syncAgentDraftResultProfile( - currentProfile, - ); - const latestProfile = normalizeAgentBackedProfile( - buildCustomWorldProfileFromAgentDraft( - latestResult.session ?? agentSession, - ) ?? - latestResult.profile ?? - currentProfile, - ); - setGeneratedCustomWorldProfile(latestProfile); - handleCustomWorldSelect(latestProfile); - })().catch((error) => { - setCustomWorldError( - resolveErrorMessage(error, '进入世界前同步草稿失败。'), - ); - }); - }); - }} - readOnly={false} - compactAgentResultMode={isAgentDraftResultView} - backLabel={isAgentDraftResultView ? '返回创作' : undefined} - editActionLabel="继续调整设定" - enterWorldActionLabel="进入世界" - autoSaveState={customWorldAutoSaveState} - /> - - - )} -
- - { - if (isCreatingAgentSession) { - return; - } - setShowCreationTypeModal(false); - }} - onSelectRpg={() => { - runProtectedAction(() => { - void openRpgAgentWorkspace(); - }); - }} - /> - - ); -} diff --git a/src/components/game-shell/rpg-creation-flow/RpgCreationShell.tsx b/src/components/game-shell/rpg-creation-flow/RpgCreationShell.tsx deleted file mode 100644 index 45dc8263..00000000 --- a/src/components/game-shell/rpg-creation-flow/RpgCreationShell.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { ComponentProps } from 'react'; - -import { PreGameSelectionFlow } from '../PreGameSelectionFlow'; - -/** - * 工作包 A 先建立 RPG 创作壳层的新命名入口。 - * 当前实现继续复用旧的 `PreGameSelectionFlow`,后续工作包 B 再把内部编排逐步迁到新目录。 - */ -export type RpgCreationShellProps = ComponentProps; - -export function RpgCreationShell(props: RpgCreationShellProps) { - return ; -} - -export default RpgCreationShell; diff --git a/src/components/game-shell/rpg-creation-flow/index.ts b/src/components/game-shell/rpg-creation-flow/index.ts deleted file mode 100644 index fa548875..00000000 --- a/src/components/game-shell/rpg-creation-flow/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { - RpgCreationShell, - type RpgCreationShellProps, -} from './RpgCreationShell'; diff --git a/src/components/game-shell/useSceneTransitionModel.ts b/src/components/game-shell/useSceneTransitionModel.ts deleted file mode 100644 index d1cafd68..00000000 --- a/src/components/game-shell/useSceneTransitionModel.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; - -import type { - GameState, - StoryMoment, -} from '../../types'; - -export type SceneTransitionPhase = 'idle' | 'exiting' | 'entering'; -export type SceneTransitionTriggerMode = 'scene-change' | 'content-change'; - -type SceneTransitionRequest = { - mode: SceneTransitionTriggerMode; - baselineSceneId: string | null; - baselineContentKey: string; - exitComplete: boolean; -}; - -const DEFAULT_SCENE_SWITCH_EXIT_MS = 5000; -const DEFAULT_SCENE_SWITCH_ENTRY_MS = 5930; - -export const SCENE_TRANSITION_FUNCTION_MODES: Partial> = { - idle_travel_next_scene: 'scene-change', - camp_travel_home_scene: 'scene-change', - idle_explore_forward: 'content-change', - idle_follow_clue: 'content-change', -}; - -function buildSceneTransitionContentKey(gameState: GameState, currentStory: StoryMoment | null) { - const sceneId = gameState.currentScenePreset?.id ?? 'scene:none'; - const encounterKey = gameState.currentEncounter - ? `${gameState.currentEncounter.kind}:${gameState.currentEncounter.id ?? gameState.currentEncounter.npcName ?? 'unknown'}` - : 'encounter:none'; - const monsterKey = gameState.sceneHostileNpcs - .map(monster => `${monster.id}:${monster.renderKind}:${monster.xMeters}:${monster.animation}`) - .join('|'); - const storyKey = currentStory - ? `${currentStory.displayMode ?? 'story'}:${currentStory.text ?? ''}:${currentStory.dialogue?.length ?? 0}` - : 'story:none'; - return [sceneId, encounterKey, monsterKey, storyKey].join('::'); -} - -export function useSceneTransitionModel(params: { - gameState: GameState; - currentStory: StoryMoment | null; - openingCampSceneId: string | null; -}) { - const { - gameState, - currentStory, - openingCampSceneId, - } = params; - const [renderGameState, setRenderGameState] = useState(gameState); - const [renderCurrentStory, setRenderCurrentStory] = useState(currentStory); - const [sceneTransitionPhase, setSceneTransitionPhase] = useState('idle'); - const [sceneTransitionToken, setSceneTransitionToken] = useState(0); - const [sceneTransitionDurations, setSceneTransitionDurations] = useState({ - exitMs: DEFAULT_SCENE_SWITCH_EXIT_MS, - entryMs: DEFAULT_SCENE_SWITCH_ENTRY_MS, - }); - - const pendingScenePayloadRef = useRef<{ gameState: GameState; currentStory: StoryMoment | null }>({ - gameState, - currentStory, - }); - const sceneTransitionTimerIdsRef = useRef([]); - const sceneTransitionRequestRef = useRef(null); - - useEffect(() => { - return () => { - sceneTransitionTimerIdsRef.current.forEach(timerId => window.clearTimeout(timerId)); - sceneTransitionTimerIdsRef.current = []; - sceneTransitionRequestRef.current = null; - }; - }, []); - - const startSceneEntering = useCallback((payload: { gameState: GameState; currentStory: StoryMoment | null }) => { - sceneTransitionTimerIdsRef.current.forEach(timerId => window.clearTimeout(timerId)); - sceneTransitionTimerIdsRef.current = []; - sceneTransitionRequestRef.current = null; - setRenderGameState(payload.gameState); - setRenderCurrentStory(payload.currentStory); - setSceneTransitionToken(current => current + 1); - setSceneTransitionPhase('entering'); - - const entryTimerId = window.setTimeout(() => { - setSceneTransitionPhase('idle'); - }, sceneTransitionDurations.entryMs); - sceneTransitionTimerIdsRef.current.push(entryTimerId); - }, [sceneTransitionDurations.entryMs]); - - const beginSceneTransition = useCallback((mode: SceneTransitionTriggerMode) => { - if (sceneTransitionPhase !== 'idle') return; - - pendingScenePayloadRef.current = { gameState, currentStory }; - sceneTransitionTimerIdsRef.current.forEach(timerId => window.clearTimeout(timerId)); - sceneTransitionTimerIdsRef.current = []; - sceneTransitionRequestRef.current = { - mode, - baselineSceneId: renderGameState.currentScenePreset?.id ?? gameState.currentScenePreset?.id ?? null, - baselineContentKey: buildSceneTransitionContentKey(renderGameState, renderCurrentStory), - exitComplete: false, - }; - setSceneTransitionPhase('exiting'); - - const exitTimerId = window.setTimeout(() => { - const request = sceneTransitionRequestRef.current; - if (!request) return; - request.exitComplete = true; - - const pendingPayload = pendingScenePayloadRef.current; - const isReady = request.mode === 'scene-change' - ? (pendingPayload.gameState.currentScenePreset?.id ?? null) !== request.baselineSceneId - : buildSceneTransitionContentKey(pendingPayload.gameState, pendingPayload.currentStory) !== request.baselineContentKey; - - if (isReady) { - startSceneEntering(pendingPayload); - } - }, sceneTransitionDurations.exitMs); - sceneTransitionTimerIdsRef.current.push(exitTimerId); - }, [ - currentStory, - gameState, - renderCurrentStory, - renderGameState, - sceneTransitionDurations.exitMs, - sceneTransitionPhase, - startSceneEntering, - ]); - - useEffect(() => { - pendingScenePayloadRef.current = { gameState, currentStory }; - - const request = sceneTransitionRequestRef.current; - if (sceneTransitionPhase === 'exiting' && request?.exitComplete) { - const isReady = request.mode === 'scene-change' - ? (gameState.currentScenePreset?.id ?? null) !== request.baselineSceneId - : buildSceneTransitionContentKey(gameState, currentStory) !== request.baselineContentKey; - if (isReady) { - startSceneEntering({ gameState, currentStory }); - } - return; - } - - if (sceneTransitionPhase !== 'exiting') { - setRenderGameState(gameState); - setRenderCurrentStory(currentStory); - } - }, [currentStory, gameState, sceneTransitionPhase, startSceneEntering]); - - useEffect(() => { - if (sceneTransitionPhase !== 'idle') { - return; - } - if (renderGameState.playerCharacter) { - return; - } - if (!gameState.playerCharacter || gameState.currentScene !== 'Story') { - return; - } - if (gameState.storyHistory.length > 0) { - return; - } - if (!openingCampSceneId || gameState.currentScenePreset?.id !== openingCampSceneId) { - return; - } - - startSceneEntering({ gameState, currentStory }); - }, [ - currentStory, - gameState, - openingCampSceneId, - renderGameState.playerCharacter, - sceneTransitionPhase, - startSceneEntering, - ]); - - return { - visibleGameState: sceneTransitionPhase === 'idle' ? gameState : renderGameState, - visibleCurrentStory: sceneTransitionPhase === 'idle' ? currentStory : renderCurrentStory, - sceneTransitionPhase, - sceneTransitionToken, - setSceneTransitionDurations, - beginSceneTransition, - }; -} diff --git a/src/components/rpg-creation-asset-studio/RpgCreationRoleAnimationSection.tsx b/src/components/rpg-creation-asset-studio/RpgCreationRoleAnimationSection.tsx new file mode 100644 index 00000000..eadf4a77 --- /dev/null +++ b/src/components/rpg-creation-asset-studio/RpgCreationRoleAnimationSection.tsx @@ -0,0 +1,244 @@ +import type { ComponentType, CSSProperties, ReactNode } from 'react'; +import { RefreshCcw } from 'lucide-react'; + +import { + type AnimationState, + type Character, +} from '../../types'; +import { CORE_ACTIONS } from './roleAssetStudioModel'; + +type ActionButtonProps = { + icon?: ReactNode; + label: string; + subLabel?: string; + onClick: () => void; + disabled?: boolean; + tone?: 'default' | 'sky' | 'green'; +}; + +type FieldProps = { + label: string; + children: ReactNode; +}; + +type SectionProps = { + title: string; + children: ReactNode; +}; + +type StatusBadgeProps = { + tone: 'green' | 'amber' | 'zinc'; + children: ReactNode; +}; + +type TextAreaProps = { + value: string; + onChange: (value: string) => void; + rows?: number; + placeholder?: string; + readOnly?: boolean; +}; + +type CharacterAnimatorProps = { + state: AnimationState; + character: Character; + className?: string; + style?: CSSProperties; + imageClassName?: string; + playbackRate?: number; +}; + +export function RpgCreationRoleAnimationSection(props: { + ActionButton: (props: ActionButtonProps) => ReactNode; + CharacterAnimator: ComponentType; + Field: (props: FieldProps) => ReactNode; + Section: (props: SectionProps) => ReactNode; + StatusBadge: (props: StatusBadgeProps) => ReactNode; + TextArea: (props: TextAreaProps) => ReactNode; + animationPreviewFrameStyle: CSSProperties; + animationPreviewPlaybackRate: number; + animationPreviewViewportStyle: CSSProperties; + animationPromptText: string; + generatingAnimationMap: Partial>; + hasGeneratedAnimation: (animation: AnimationState) => boolean; + isSelectedAnimationGenerating: boolean; + previewCharacter: Character | null; + previewImageSrc: string; + selectedAnimation: AnimationState; + selectedAnimationStatus: string | null; + shouldUseSelectedAnimationPreview: boolean; + syncBusy: boolean; + animationPointCost: number; + workingRoleImageSrc?: string; + workingRoleGeneratedVisualAssetId?: string; + workingRoleName: string; + onAnimationPromptChange: (value: string) => void; + onGenerateAnimation: () => void; + onPlaybackRateChange: (value: number) => void; + onSelectAnimation: (animation: AnimationState) => void; +}) { + const { + ActionButton, + CharacterAnimator, + Field, + Section, + StatusBadge, + TextArea, + animationPreviewFrameStyle, + animationPreviewPlaybackRate, + animationPreviewViewportStyle, + animationPromptText, + generatingAnimationMap, + hasGeneratedAnimation, + isSelectedAnimationGenerating, + previewCharacter, + previewImageSrc, + selectedAnimation, + selectedAnimationStatus, + shouldUseSelectedAnimationPreview, + syncBusy, + animationPointCost, + workingRoleGeneratedVisualAssetId, + workingRoleImageSrc, + workingRoleName, + onAnimationPromptChange, + onGenerateAnimation, + onPlaybackRateChange, + onSelectAnimation, + } = props; + + return ( +
+
+
+
+ {shouldUseSelectedAnimationPreview && previewCharacter ? ( +
+
+ +
+
+ ) : previewImageSrc ? ( + {workingRoleName} + ) : ( +
暂无动作预览
+ )} +
+
+ + +
+ + onPlaybackRateChange(Number.parseFloat(event.target.value) || 0.75) + } + className="w-full accent-sky-400" + /> +
+ 0.25x + {animationPreviewPlaybackRate.toFixed(2)}x + 1.50x +
+
+
+ +
+ {CORE_ACTIONS.map((item) => { + const isSelected = item.animation === selectedAnimation; + const isReady = hasGeneratedAnimation(item.animation); + const isGenerating = generatingAnimationMap[item.animation] === true; + + return ( + + ); + })} +
+ + +