Files
Genarrative/server-node/src/routes/runtimeRoutes.ts
高物 50759f3c1e
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-20 09:54:17 +08:00

957 lines
28 KiB
TypeScript

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<string, unknown>;
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<string, unknown>;
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<ProfileDashboardSummary>(
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<ProfileWalletLedgerResponse>(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<ProfilePlayStatsResponse>(
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<PlatformBrowseHistoryResponse>(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<PlatformBrowseHistoryResponse>(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<PlatformBrowseHistoryResponse>(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<ProfileSaveArchiveListResponse>(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<ProfileSaveArchiveResumeResponse>(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<ListCustomWorldWorksResponse>(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<string, unknown>) => {
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<string, unknown>,
);
},
});
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;
}