1
This commit is contained in:
@@ -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) {
|
||||
|
||||
11
server-node/src/routes/rpg-entry/index.ts
Normal file
11
server-node/src/routes/rpg-entry/index.ts
Normal 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';
|
||||
151
server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts
Normal file
151
server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts
Normal 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;
|
||||
}
|
||||
338
server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts
Normal file
338
server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts
Normal 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;
|
||||
}
|
||||
4
server-node/src/routes/rpg-profile/index.ts
Normal file
4
server-node/src/routes/rpg-profile/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createRpgProfileRoutes,
|
||||
RPG_PROFILE_ROUTE_BASE_PATH,
|
||||
} from './rpgProfileRoutes.js';
|
||||
214
server-node/src/routes/rpg-profile/rpgProfileRoutes.ts
Normal file
214
server-node/src/routes/rpg-profile/rpgProfileRoutes.ts
Normal 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;
|
||||
}
|
||||
8
server-node/src/routes/rpg-runtime/index.ts
Normal file
8
server-node/src/routes/rpg-runtime/index.ts
Normal 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';
|
||||
370
server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts
Normal file
370
server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts
Normal 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;
|
||||
}
|
||||
2741
server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.test.ts
Normal file
2741
server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
104
server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts
Normal file
104
server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts
Normal 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;
|
||||
}
|
||||
524
server-node/src/routes/rpgRouteBoundaries.test.ts
Normal file
524
server-node/src/routes/rpgRouteBoundaries.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user