import { Router } from 'express'; import { z } from 'zod'; import type { ListCustomWorldWorksResponse } from '../../../packages/shared/src/contracts/customWorldAgent.js'; import type { AnswerCustomWorldSessionQuestionRequest, CreateCustomWorldSessionRequest, CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse, CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse, PlatformBrowseHistoryBatchSyncRequest, PlatformBrowseHistoryResponse, PlatformBrowseHistoryWriteEntry, ProfileDashboardSummary, ProfilePlayStatsResponse, ProfileSaveArchiveListResponse, ProfileSaveArchiveResumeResponse, ProfileWalletLedgerResponse, RuntimeSettings, SavedGameSnapshotInput, } from '../../../packages/shared/src/contracts/runtime.js'; import { CUSTOM_WORLD_GENERATION_MODES, 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, prepareEventStreamResponse, 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 { 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 { generateCustomWorldProfile } from '../services/customWorldGenerationService.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 customWorldSceneNpcSchema = z.object({ profile: jsonObjectSchema, landmarkId: z.string().trim().min(1), }); const customWorldEntitySchema = z.object({ profile: jsonObjectSchema, kind: z.enum(['playable', 'story', 'landmark']), }); const customWorldSessionSchema = z.object({ settingText: z.string().trim().min(1), creatorIntent: jsonObjectSchema.nullable().optional().default(null), generationMode: z.enum(CUSTOM_WORLD_GENERATION_MODES).default('fast'), }); const customWorldAnswerSchema = z.object({ questionId: z.string().trim().min(1), answer: z.string().trim().min(1), }); 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-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/custom-world/sessions', routeMeta({ operation: 'runtime.customWorldSession.create' }), asyncHandler(async (request, response) => { const payload = customWorldSessionSchema.parse( request.body, ) as CreateCustomWorldSessionRequest; sendApiResponse( response, await context.customWorldSessions.create( request.userId!, payload.settingText, payload.creatorIntent, payload.generationMode, ), ); }), ); router.get( '/runtime/custom-world/sessions/:sessionId', routeMeta({ operation: 'runtime.customWorldSession.get' }), asyncHandler(async (request, response) => { const session = await context.customWorldSessions.get( request.userId!, readParam(request.params.sessionId), ); if (!session) { throw notFound('custom world session not found'); } sendApiResponse(response, session); }), ); router.post( '/runtime/custom-world/sessions/:sessionId/answers', routeMeta({ operation: 'runtime.customWorldSession.answer' }), asyncHandler(async (request, response) => { const payload = customWorldAnswerSchema.parse( request.body, ) as AnswerCustomWorldSessionQuestionRequest; const session = await context.customWorldSessions.answer( request.userId!, readParam(request.params.sessionId), payload.questionId, payload.answer, ); if (!session) { throw notFound('custom world session not found'); } sendApiResponse(response, session); }), ); router.get( '/runtime/custom-world/sessions/:sessionId/generate/stream', routeMeta({ operation: 'runtime.customWorldSession.generateStream' }), asyncHandler(async (request, response) => { const session = await context.customWorldSessions.get( request.userId!, readParam(request.params.sessionId), ); if (!session) { throw notFound('custom world session not found'); } prepareEventStreamResponse(request, response); const controller = new AbortController(); request.on('close', () => { controller.abort(); }); const writeEvent = (event: string, payload: Record) => { response.write(`event: ${event}\n`); response.write(`data: ${JSON.stringify(payload)}\n\n`); }; writeEvent('progress', { phase: 'preparing', progress: 10 }); await context.customWorldSessions.updateStatus( request.userId!, readParam(request.params.sessionId), 'generating', ); writeEvent('progress', { phase: 'requesting_llm', progress: 45 }); try { const profile = await generateCustomWorldProfile(context, session, { signal: controller.signal, onProgress: (progress) => { writeEvent( 'progress', progress as unknown as Record, ); }, }); await context.customWorldSessions.setResult( request.userId!, readParam(request.params.sessionId), profile, ); writeEvent('progress', { phase: 'completed', progress: 100 }); writeEvent('result', { profile }); writeEvent('done', { ok: true }); } catch (error) { const message = error instanceof Error ? error.message : 'custom world generation failed'; await context.customWorldSessions.updateStatus( request.userId!, readParam(request.params.sessionId), 'generation_error', message, ); writeEvent('error', { message }); } finally { response.end(); } }), ); 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; }