import { Router } from 'express'; import { z } from 'zod'; import type { AnswerCustomWorldSessionQuestionRequest, CreateCustomWorldSessionRequest, CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse, CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse, RuntimeSettings, SavedGameSnapshotInput, } from '../../../packages/shared/src/contracts/runtime.js'; import { CUSTOM_WORLD_GENERATION_MODES } from '../../../packages/shared/src/contracts/runtime.js'; import type { QuestGenerationRequest, RuntimeItemIntentRequest, } from '../../../packages/shared/src/contracts/story.js'; import type { CharacterChatReplyRequest, CharacterChatSuggestionsRequest, CharacterChatSummaryRequest, NpcChatDialogueRequest, 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, streamNpcRecruitDialogueFromOrchestrator, } from '../modules/ai/chatOrchestrator.js'; import { hydrateSavedSnapshot, normalizeSavedSnapshotPayload, } from '../modules/runtime/runtimeSnapshotHydration.js'; import { characterChatReplyRequestSchema, characterChatSuggestionsRequestSchema, characterChatSummaryRequestSchema, npcChatDialogueRequestSchema, npcRecruitDialogueRequestSchema, } from '../services/chatService.js'; import { generateCustomWorldProfile } from '../services/customWorldGenerationService.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 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), }); const customWorldProfileSchema = z.object({ profile: jsonObjectSchema, }); 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); router.use(requireAuth); 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/scene-image', routeMeta({ operation: 'runtime.customWorld.sceneImage' }), asyncHandler(async (request, response) => { const payload = sceneImageSchema.parse(request.body); sendApiResponse(response, await generateSceneImage(context, payload)); }), ); 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-library', routeMeta({ operation: 'runtime.customWorldLibrary.list' }), asyncHandler(async (request, response) => { sendApiResponse( response, { entries: await context.runtimeRepository.listCustomWorldProfiles( request.userId!, ), } satisfies CustomWorldLibraryResponse, ); }), ); 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.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/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, 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 = 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 = 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 = 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 }); 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, ); }, }); 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'; 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; }