This commit is contained in:
2026-04-21 18:27:46 +08:00
parent 04bff9617d
commit 4372ab5be1
358 changed files with 30788 additions and 14737 deletions

View File

@@ -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) {

View File

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

View File

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

View File

@@ -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<string, unknown>)
: 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<ListCustomWorldWorksResponse>(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<string, unknown>,
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;
}

View File

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

View File

@@ -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<ProfileDashboardSummary>(
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<ProfileWalletLedgerResponse>(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<ProfilePlayStatsResponse>(
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<PlatformBrowseHistoryResponse>(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<PlatformBrowseHistoryResponse>(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<PlatformBrowseHistoryResponse>(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;
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,104 @@
import { Router } from 'express';
import { z } from 'zod';
import type {
RuntimeStoryActionRequest,
RuntimeStoryStateRequest,
} 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 } from '../../modules/rpg-runtime-story/RpgRuntimeStoryStateService.js';
import { resolveRuntimeStoryAction } from '../../modules/rpg-runtime-story/RpgRuntimeStoryActionService.js';
const actionPayloadSchema = z.record(z.string(), z.unknown());
const runtimeStoryActionSchema = z.object({
sessionId: z.string().trim().min(1),
clientVersion: z.number().int().min(0).optional(),
snapshot: z.unknown().optional(),
action: z.object({
type: z.literal('story_choice'),
functionId: z.string().trim().min(1),
targetId: z.string().trim().optional(),
payload: actionPayloadSchema.optional().default({}),
}),
});
const runtimeStoryStateResolveSchema = z.object({
sessionId: z.string().trim().min(1),
clientVersion: z.number().int().min(0).optional(),
snapshot: z.unknown().optional(),
});
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);
router.use(requireAuth);
router.post(
'/actions/resolve',
routeMeta({ operation: 'runtime.story.actions.resolve' }),
asyncHandler(async (request, response) => {
const payload = runtimeStoryActionSchema.parse(
request.body,
) as RuntimeStoryActionRequest;
sendApiResponse(
response,
await resolveRuntimeStoryAction({
snapshotRepository: context.rpgRuntimeSnapshotRepository,
llmClient: context.llmClient,
userId: request.userId!,
request: payload,
}),
);
}),
);
router.get(
'/state/:sessionId',
routeMeta({ operation: 'runtime.story.state.get' }),
asyncHandler(async (request, response) => {
const sessionId = request.params.sessionId?.trim() || '';
if (!sessionId) {
throw badRequest('sessionId is required');
}
sendApiResponse(
response,
await getRuntimeStoryState({
snapshotRepository: context.rpgRuntimeSnapshotRepository,
userId: request.userId!,
sessionId,
}),
);
}),
);
router.post(
'/state/resolve',
routeMeta({ operation: 'runtime.story.state.resolve' }),
asyncHandler(async (request, response) => {
const payload = runtimeStoryStateResolveSchema.parse(
request.body,
) as RuntimeStoryStateRequest;
sendApiResponse(
response,
await getRuntimeStoryState({
snapshotRepository: context.rpgRuntimeSnapshotRepository,
userId: request.userId!,
sessionId: payload.sessionId,
clientVersion: payload.clientVersion,
snapshot: payload.snapshot,
}),
);
}),
);
return router;
}

View File

@@ -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<T>(
testName: string,
run: (options: { baseUrl: string }) => Promise<T>,
) {
const context = await createAppContext(createTestConfig(testName));
const app = createApp(context);
const server = await new Promise<import('node:http').Server>((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<void>((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<string, unknown>,
) {
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);
});
});

View File

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

View File

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

View File

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