Merge branch 'codex/dev' into codex/backend-rewrite-spacetimedb

# Conflicts:
#	docs/technical/README.md
#	server-node/src/modules/assets/qwenSpriteRoutes.ts
#	src/components/CustomWorldResultView.test.tsx
#	src/components/CustomWorldResultView.tsx
#	src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx
#	src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx
#	src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx
#	src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx
#	src/components/rpg-entry/RpgEntryCharacterSelectView.tsx
#	src/components/rpg-entry/RpgEntryHomeView.tsx
#	src/services/apiClient.ts
#	src/tools/QwenSpriteSheetTool.tsx
This commit is contained in:
2026-04-21 20:16:01 +08:00
477 changed files with 38047 additions and 26570 deletions

View File

@@ -122,6 +122,11 @@ function createTestConfig(
mockAvatarUrl: '',
},
authSession: {
accessCookieName: 'genarrative_access_session',
accessCookieTtlSeconds: 7200,
accessCookieSecure: false,
accessCookieSameSite: 'Lax',
accessCookiePath: '/',
refreshCookieName: 'genarrative_refresh_session',
refreshSessionTtlDays: 30,
refreshCookieSecure: false,
@@ -203,7 +208,10 @@ async function authEntry(baseUrl: string, username: string, password: string) {
username: string;
};
};
const refreshCookie = response.headers.get('set-cookie');
const refreshCookie = buildCookieHeader(
response.headers.get('set-cookie'),
'genarrative_refresh_session',
);
assert.equal(response.status, 200);
assert.ok(payload.token);
@@ -258,7 +266,10 @@ async function phoneLogin(baseUrl: string, phone: string, code = '123456') {
wechatBound: boolean;
};
};
const refreshCookie = response.headers.get('set-cookie');
const refreshCookie = buildCookieHeader(
response.headers.get('set-cookie'),
'genarrative_refresh_session',
);
assert.equal(response.status, 200);
assert.ok(payload.token);
@@ -444,6 +455,191 @@ async function createObjectRefiningCustomWorldAgentSession(params: {
return session;
}
async function markAgentSessionPublishReady(params: {
context: TestAppContext;
userId: string;
sessionId: string;
}) {
const snapshot = await params.context.customWorldAgentOrchestrator.getSessionSnapshot(
params.userId,
params.sessionId,
);
const draftProfile = snapshot?.draftProfile as Record<string, unknown> | null;
const playableNpcs = Array.isArray(draftProfile?.playableNpcs)
? (draftProfile?.playableNpcs as Array<Record<string, unknown>>)
: [];
const storyNpcs = Array.isArray(draftProfile?.storyNpcs)
? (draftProfile?.storyNpcs as Array<Record<string, unknown>>)
: [];
const landmarks = Array.isArray(draftProfile?.landmarks)
? (draftProfile?.landmarks as Array<Record<string, unknown>>)
: [];
const sceneChapters = Array.isArray(draftProfile?.sceneChapters)
? (draftProfile?.sceneChapters as Array<Record<string, unknown>>)
: [];
const camp =
draftProfile?.camp && typeof draftProfile.camp === 'object'
? (draftProfile.camp as Record<string, unknown>)
: null;
const firstPlayableRoleId =
typeof playableNpcs[0]?.id === 'string' && playableNpcs[0]?.id.trim()
? playableNpcs[0].id.trim()
: null;
const firstStoryRoleId =
typeof storyNpcs[0]?.id === 'string' && storyNpcs[0]?.id.trim()
? storyNpcs[0].id.trim()
: firstPlayableRoleId;
assert.ok(snapshot);
assert.ok(draftProfile);
assert.ok(playableNpcs.length > 0);
assert.ok(storyNpcs.length > 0);
assert.ok(landmarks.length > 0);
assert.ok(sceneChapters.length > 0);
assert.ok(firstStoryRoleId);
await params.context.customWorldAgentSessions.replaceDerivedState(
params.userId,
params.sessionId,
{
stage: 'ready_to_publish',
qualityFindings: [],
draftProfile: {
...draftProfile,
chapters:
Array.isArray(draftProfile.chapters) && draftProfile.chapters.length > 0
? draftProfile.chapters
: [{ id: 'chapter-main-1', title: '主线第一章' }],
camp: {
...(camp ?? {}),
id:
typeof camp?.id === 'string' && camp.id.trim()
? camp.id.trim()
: 'camp-home',
name:
typeof camp?.name === 'string' && camp.name.trim()
? camp.name.trim()
: '归潮营地',
description:
typeof camp?.description === 'string' && camp.description.trim()
? camp.description.trim()
: '可供玩家整理线索的临时据点。',
imageSrc:
typeof camp?.imageSrc === 'string' && camp.imageSrc.trim()
? camp.imageSrc.trim()
: '/generated/camp/publish-ready.png',
generatedSceneAssetId:
typeof camp?.generatedSceneAssetId === 'string' &&
camp.generatedSceneAssetId.trim()
? camp.generatedSceneAssetId.trim()
: 'scene-camp-publish-ready',
generatedScenePrompt:
typeof camp?.generatedScenePrompt === 'string' &&
camp.generatedScenePrompt.trim()
? camp.generatedScenePrompt.trim()
: '潮雾营地发布正式图',
generatedSceneModel:
typeof camp?.generatedSceneModel === 'string' &&
camp.generatedSceneModel.trim()
? camp.generatedSceneModel.trim()
: 'test-scene-model',
},
playableNpcs: playableNpcs.map((entry, index) => ({
...entry,
imageSrc:
typeof entry.imageSrc === 'string' && entry.imageSrc.trim()
? entry.imageSrc.trim()
: `/generated/playable/publish-ready-${index + 1}.png`,
generatedVisualAssetId:
typeof entry.generatedVisualAssetId === 'string' &&
entry.generatedVisualAssetId.trim()
? entry.generatedVisualAssetId.trim()
: `visual-playable-publish-${index + 1}`,
generatedAnimationSetId:
typeof entry.generatedAnimationSetId === 'string' &&
entry.generatedAnimationSetId.trim()
? entry.generatedAnimationSetId.trim()
: `anim-playable-publish-${index + 1}`,
})),
storyNpcs: storyNpcs.map((entry, index) => ({
...entry,
imageSrc:
typeof entry.imageSrc === 'string' && entry.imageSrc.trim()
? entry.imageSrc.trim()
: `/generated/story/publish-ready-${index + 1}.png`,
generatedVisualAssetId:
typeof entry.generatedVisualAssetId === 'string' &&
entry.generatedVisualAssetId.trim()
? entry.generatedVisualAssetId.trim()
: `visual-story-publish-${index + 1}`,
generatedAnimationSetId:
typeof entry.generatedAnimationSetId === 'string' &&
entry.generatedAnimationSetId.trim()
? entry.generatedAnimationSetId.trim()
: `anim-story-publish-${index + 1}`,
})),
landmarks: landmarks.map((entry, index) => ({
...entry,
imageSrc:
typeof entry.imageSrc === 'string' && entry.imageSrc.trim()
? entry.imageSrc.trim()
: `/generated/landmark/publish-ready-${index + 1}.png`,
generatedSceneAssetId:
typeof entry.generatedSceneAssetId === 'string' &&
entry.generatedSceneAssetId.trim()
? entry.generatedSceneAssetId.trim()
: `scene-landmark-publish-${index + 1}`,
generatedScenePrompt:
typeof entry.generatedScenePrompt === 'string' &&
entry.generatedScenePrompt.trim()
? entry.generatedScenePrompt.trim()
: `地点 ${typeof entry.name === 'string' ? entry.name : index + 1} 的正式场景图`,
generatedSceneModel:
typeof entry.generatedSceneModel === 'string' &&
entry.generatedSceneModel.trim()
? entry.generatedSceneModel.trim()
: 'test-scene-model',
})),
sceneChapters: sceneChapters.map((chapter, chapterIndex) => {
const acts = Array.isArray(chapter.acts)
? (chapter.acts as Array<Record<string, unknown>>)
: [];
return {
...chapter,
linkedThreadIds:
Array.isArray(chapter.linkedThreadIds) &&
chapter.linkedThreadIds.length > 0
? chapter.linkedThreadIds
: ['thread-publish-ready'],
acts: acts.map((act, actIndex) => ({
...act,
encounterNpcIds:
Array.isArray(act.encounterNpcIds) && act.encounterNpcIds.length > 0
? act.encounterNpcIds
: [firstStoryRoleId],
primaryNpcId:
typeof act.primaryNpcId === 'string' && act.primaryNpcId.trim()
? act.primaryNpcId.trim()
: firstStoryRoleId,
backgroundImageSrc:
typeof act.backgroundImageSrc === 'string' &&
act.backgroundImageSrc.trim()
? act.backgroundImageSrc.trim()
: `/generated/scene/publish-ready-${chapterIndex + 1}-${actIndex + 1}.png`,
backgroundAssetId:
typeof act.backgroundAssetId === 'string' &&
act.backgroundAssetId.trim()
? act.backgroundAssetId.trim()
: `scene-act-publish-${chapterIndex + 1}-${actIndex + 1}`,
})),
};
}),
},
},
);
}
function parseRedirectHash(location: string) {
const url = new URL(location, 'http://127.0.0.1');
return new URLSearchParams(
@@ -451,6 +647,18 @@ function parseRedirectHash(location: string) {
);
}
function readCookieValue(cookieHeader: string, cookieName: string) {
const match = cookieHeader.match(
new RegExp(`${cookieName}=([^;,\r\n]+)`, 'u'),
);
return match?.[1] ? decodeURIComponent(match[1]) : '';
}
function buildCookieHeader(cookieHeader: string | null | undefined, cookieName: string) {
const value = readCookieValue(cookieHeader || '', cookieName);
return value ? `${cookieName}=${encodeURIComponent(value)}` : '';
}
async function startWechatMockFlow(baseUrl: string, redirectPath = '/') {
const startResponse = await httpRequest(
`${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent(redirectPath)}`,
@@ -467,8 +675,7 @@ async function startWechatMockFlow(baseUrl: string, redirectPath = '/') {
const location = callbackResponse.headers.get('location') || '';
assert.ok(location);
const hash = parseRedirectHash(location);
const token = hash.get('auth_token') || '';
const token = hash.get('auth_token')?.trim() || '';
assert.ok(token);
return {
@@ -1536,7 +1743,10 @@ test('logout-all revokes all refresh sessions and invalidates existing access to
assert.equal(refreshResponse.status, 200);
const entryB = {
token: refreshPayload.token,
refreshCookie: refreshResponse.headers.get('set-cookie') || '',
refreshCookie: buildCookieHeader(
refreshResponse.headers.get('set-cookie'),
'genarrative_refresh_session',
),
};
const logoutAllResponse = await httpRequest(
@@ -2503,6 +2713,34 @@ test('custom world works endpoint returns draft sessions and published worlds to
assert.equal(publishResponse.status, 200);
const publishMutationResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library/world-published/publish`,
withBearer(entry.token, {
method: 'POST',
}),
);
assert.equal(publishMutationResponse.status, 200);
const draftOnlyResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library/world-draft-only`,
withBearer(entry.token, {
method: 'PUT',
body: JSON.stringify({
profile: {
id: 'world-draft-only',
name: '旧兼容草稿',
subtitle: '仍保留在作品库,但不再进入创作中心',
summary: '这个条目用来验证阶段三不会继续把 library draft 当主草稿展示。',
playableNpcs: [{ id: 'hero-draft', name: '旧草稿角色' }],
landmarks: [{ id: 'port-draft', name: '旧草稿地点' }],
},
}),
}),
);
assert.equal(draftOnlyResponse.status, 200);
const worksResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/works`,
{
@@ -2542,6 +2780,10 @@ test('custom world works endpoint returns draft sessions and published worlds to
item.canEnterWorld === true,
),
);
assert.equal(
worksPayload.items.some((item) => item.profileId === 'world-draft-only'),
false,
);
});
});
@@ -2847,6 +3089,98 @@ test('custom world agent draft_foundation action generates draft cards and card
);
});
test('custom world agent stream message returns enriched session payload over sse', async () => {
await withTestServer(
'custom-world-agent-stream-session',
async ({ baseUrl, context }) => {
installTestCustomWorldAgentSingleTurnLlm(context);
const entry = await authEntry(baseUrl, 'cw_agent_stream', 'secret123');
const readySession = await createReadyCustomWorldAgentSession({
baseUrl,
token: entry.token,
});
const foundationResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/actions`,
withBearer(entry.token, {
method: 'POST',
body: JSON.stringify({
action: 'draft_foundation',
}),
}),
);
const foundationPayload = (await foundationResponse.json()) as {
operation: {
operationId: string;
};
};
assert.equal(foundationResponse.status, 200);
await waitForCustomWorldAgentOperation({
baseUrl,
token: entry.token,
sessionId: readySession.sessionId,
operationId: foundationPayload.operation.operationId,
expectedStatus: 'completed',
});
const streamResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/messages/stream`,
withBearer(entry.token, {
method: 'POST',
headers: {
Accept: 'text/event-stream',
},
body: JSON.stringify({
clientMessageId: 'stream-client-1',
text: '把守灯会的压力再收紧一些,玩家与沈砺的旧案关系也再明显一点。',
focusCardId: null,
selectedCardIds: [],
}),
}),
);
const streamText = await streamResponse.text();
const sessionEventMatch = streamText.match(/event: session\s+data: (\{.*\})/u);
assert.equal(streamResponse.status, 200);
assert.match(
streamResponse.headers.get('content-type') ?? '',
/text\/event-stream/u,
);
assert.match(streamText, /event: reply_delta/u);
assert.match(streamText, /event: session/u);
assert.match(streamText, /event: done/u);
assert.ok(sessionEventMatch?.[1]);
const sessionEvent = JSON.parse(sessionEventMatch![1]) as {
session: {
stage: string;
supportedActions?: Array<{ action: string; enabled: boolean }>;
resultPreview?: {
source: string;
preview: { name?: string };
} | null;
};
};
assert.equal(sessionEvent.session.stage, 'object_refining');
assert.equal(
sessionEvent.session.supportedActions?.some(
(entry) =>
entry.action === 'update_draft_card' && entry.enabled === true,
),
true,
);
assert.equal(
sessionEvent.session.resultPreview?.source,
'session_preview',
);
assert.ok(sessionEvent.session.resultPreview?.preview?.name);
},
);
});
test('custom world agent draft_foundation action rejects not-ready sessions over http', async () => {
await withTestServer(
'custom-world-agent-phase3-http-not-ready',
@@ -3038,6 +3372,240 @@ test('custom world agent update_draft_card action updates draft profile and card
);
});
test('custom world agent sync_result_profile action writes result snapshot back over http', async () => {
await withTestServer(
'custom-world-agent-sync-result-profile-http',
async ({ baseUrl, context }) => {
installTestCustomWorldAgentSingleTurnLlm(context);
const entry = await authEntry(
baseUrl,
'cw_agent_sync_result',
'secret123',
);
const session = await createObjectRefiningCustomWorldAgentSession({
baseUrl,
token: entry.token,
});
const actionResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}/actions`,
withBearer(entry.token, {
method: 'POST',
body: JSON.stringify({
action: 'sync_result_profile',
profile: {
id: `agent-draft-${session.sessionId}`,
settingText: '被海雾吞没的旧航路群岛',
name: '潮雾列岛·结果页回写版',
subtitle: '旧灯塔与失控航路',
summary: '结果页里的最新世界概述已经回写到当前草稿。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船夜与假航灯背后的操盘链。',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
attributeSchema: {
id: 'schema:test',
worldId: 'CUSTOM',
schemaVersion: 1,
schemaName: '测试',
generatedFrom: {
worldType: 'CUSTOM',
worldName: '潮雾列岛·结果页回写版',
settingSummary: '测试',
tone: '测试',
conflictCore: '测试',
},
slots: [],
},
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [],
generationMode: 'full',
generationStatus: 'complete',
},
}),
}),
);
const actionPayload = (await actionResponse.json()) as {
operation: {
operationId: string;
status: string;
};
};
assert.equal(actionResponse.status, 200);
assert.equal(actionPayload.operation.status, 'queued');
await waitForCustomWorldAgentOperation({
baseUrl,
token: entry.token,
sessionId: session.sessionId,
operationId: actionPayload.operation.operationId,
expectedStatus: 'completed',
});
const sessionResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}`,
{
headers: {
Authorization: `Bearer ${entry.token}`,
},
},
);
const sessionPayload = (await sessionResponse.json()) as {
draftProfile: {
name?: string;
summary?: string;
legacyResultProfile?: {
name?: string;
playerGoal?: string;
};
} | null;
};
assert.equal(sessionResponse.status, 200);
assert.equal(sessionPayload.draftProfile?.name, '潮雾列岛·结果页回写版');
assert.equal(
sessionPayload.draftProfile?.summary,
'结果页里的最新世界概述已经回写到当前草稿。',
);
assert.equal(
sessionPayload.draftProfile?.legacyResultProfile?.name,
'潮雾列岛·结果页回写版',
);
assert.equal(
sessionPayload.draftProfile?.legacyResultProfile?.playerGoal,
'查清沉船夜与假航灯背后的操盘链。',
);
},
);
});
test('library publish for agent-backed draft returns explicit blocker message when Phase4 gate is not ready', async () => {
await withTestServer(
'custom-world-library-agent-publish-blocked',
async ({ baseUrl, context }) => {
installTestCustomWorldAgentSingleTurnLlm(context);
const entry = await authEntry(
baseUrl,
'cw_library_agent_blocked',
'secret123',
);
const session = await createObjectRefiningCustomWorldAgentSession({
baseUrl,
token: entry.token,
});
const profileId = `agent-draft-${session.sessionId}`;
const publishResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library/${encodeURIComponent(profileId)}/publish`,
withBearer(entry.token, {
method: 'POST',
}),
);
const publishPayload = (await publishResponse.json()) as {
error: {
code: string;
message: string;
};
};
const sessionAfterPublishAttempt =
await context.customWorldAgentOrchestrator.getSessionSnapshot(
entry.user.id,
session.sessionId,
);
assert.equal(publishResponse.status, 409);
assert.equal(publishPayload.error.code, 'CONFLICT');
assert.match(
publishPayload.error.message,
/ \d+ blocker/u,
);
assert.match(
publishPayload.error.message,
/||线/u,
);
assert.notEqual(sessionAfterPublishAttempt?.stage, 'published');
},
);
});
test('library publish for agent-backed draft reuses Phase4 gate and syncs session into published stage', async () => {
await withTestServer(
'custom-world-library-agent-publish-success',
async ({ baseUrl, context }) => {
installTestCustomWorldAgentSingleTurnLlm(context);
const entry = await authEntry(
baseUrl,
'cw_library_agent_success',
'secret123',
);
const session = await createObjectRefiningCustomWorldAgentSession({
baseUrl,
token: entry.token,
});
const profileId = `agent-draft-${session.sessionId}`;
await markAgentSessionPublishReady({
context,
userId: entry.user.id,
sessionId: session.sessionId,
});
const publishResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library/${encodeURIComponent(profileId)}/publish`,
withBearer(entry.token, {
method: 'POST',
}),
);
const publishPayload = (await publishResponse.json()) as {
entry: {
profileId: string;
visibility: 'draft' | 'published';
};
};
const libraryResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library`,
withBearer(entry.token),
);
const libraryPayload = (await libraryResponse.json()) as {
entries: Array<{
profileId: string;
visibility: 'draft' | 'published';
}>;
};
const sessionAfterPublish =
await context.customWorldAgentOrchestrator.getSessionSnapshot(
entry.user.id,
session.sessionId,
);
assert.equal(publishResponse.status, 200);
assert.equal(publishPayload.entry.profileId, profileId);
assert.equal(publishPayload.entry.visibility, 'published');
assert.equal(libraryResponse.status, 200);
assert.equal(
libraryPayload.entries.find((item) => item.profileId === profileId)
?.visibility,
'published',
);
assert.equal(sessionAfterPublish?.stage, 'published');
assert.equal(sessionAfterPublish?.resultPreview?.publishReady, true);
assert.equal(sessionAfterPublish?.resultPreview?.canEnterWorld, true);
assert.deepEqual(sessionAfterPublish?.resultPreview?.blockers ?? [], []);
assert.ok(
sessionAfterPublish?.messages.some(
(message) =>
message.kind === 'action_result' &&
message.text.includes('已正式发布'),
),
);
},
);
});
test('custom world agent generate_characters action appends character cards over http', async () => {
await withTestServer(
'custom-world-agent-phase4-generate-characters-http',

View File

@@ -8,11 +8,14 @@ import { errorHandler } from './middleware/errorHandler.js';
import { requestIdMiddleware } from './middleware/requestId.js';
import { responseEnvelopeMiddleware } from './middleware/responseEnvelope.js';
import { createCharacterAssetRoutes } from './modules/assets/characterAssetRoutes.js';
import { createQwenSpriteRoutes } from './modules/assets/qwenSpriteRoutes.js';
import { createEditorRoutes } from './modules/editor/editorRoutes.js';
import { createStoryActionRoutes } from './modules/story/storyActionRoutes.js';
import { createAuthRoutes } from './routes/authRoutes.js';
import { createRuntimeRoutes } from './routes/runtimeRoutes.js';
import { createRpgEntrySaveRoutes } from './routes/rpg-entry/rpgEntrySaveRoutes.js';
import { createRpgWorldLibraryRoutes } from './routes/rpg-entry/rpgWorldLibraryRoutes.js';
import { createRpgProfileRoutes } from './routes/rpg-profile/rpgProfileRoutes.js';
import { createRpgRuntimeAiAssistRoutes } from './routes/rpg-runtime/rpgRuntimeAiAssistRoutes.js';
import { createRpgRuntimeStoryRoutes } from './routes/rpg-runtime/rpgRuntimeStoryRoutes.js';
import { createCustomWorldAgentRoutes } from './routes/customWorldAgent.js';
function matchesRoutePrefix(
request: express.Request,
@@ -120,16 +123,31 @@ export function createApp(context: AppContext) {
),
);
app.use(
'/api',
scopeToPrefixes(
['/api/assets/qwen-sprite'],
withRouteMeta({ routeVersion: '2026-04-08', operation: 'assets.qwen' }),
['/runtime/profile', '/profile', '/runtime/settings'],
withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.profile.api' }),
),
createRpgProfileRoutes(context),
);
app.use(
'/api',
scopeToPrefixes(
['/api/assets/qwen-sprite'],
createQwenSpriteRoutes(context.config),
['/runtime/save', '/runtime/profile/save-archives', '/profile/save-archives'],
withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.entry.save.api' }),
),
createRpgEntrySaveRoutes(context),
);
app.use(
'/api',
scopeToPrefixes(
['/runtime/custom-world-gallery', '/runtime/custom-world/works', '/runtime/custom-world-library'],
withRouteMeta({
routeVersion: '2026-04-21',
operation: 'rpg.entry.worldLibrary.api',
}),
),
createRpgWorldLibraryRoutes(context),
);
app.use(
'/api/auth',
@@ -138,13 +156,61 @@ export function createApp(context: AppContext) {
);
app.use(
'/api/runtime/story',
withRouteMeta({ routeVersion: '2026-04-08' }),
createStoryActionRoutes(context),
withRouteMeta({ routeVersion: '2026-04-21' }),
createRpgRuntimeStoryRoutes(context),
);
app.use(
scopeToPrefixes(
[
'/llm/chat/completions',
'/custom-world/cover-image',
'/custom-world/cover-upload',
'/custom-world/scene-image',
'/custom-world/entity',
'/custom-world/scene-npc',
'/runtime/custom-world/entity',
'/runtime/custom-world/scene-npc',
'/runtime/custom-world/profile',
'/runtime/story/initial',
'/runtime/story/continue',
'/runtime/chat',
'/runtime/items',
'/runtime/quests',
'/ws/health',
],
withRouteMeta({
routeVersion: '2026-04-21',
operation: 'rpg.runtime.aiAssist.api',
}),
),
);
app.use(
'/api',
withRouteMeta({ routeVersion: '2026-04-08' }),
createRuntimeRoutes(context),
scopeToPrefixes(
[
'/llm/chat/completions',
'/custom-world/cover-image',
'/custom-world/cover-upload',
'/custom-world/scene-image',
'/custom-world/entity',
'/custom-world/scene-npc',
'/runtime/custom-world/entity',
'/runtime/custom-world/scene-npc',
'/runtime/custom-world/profile',
'/runtime/story/initial',
'/runtime/story/continue',
'/runtime/chat',
'/runtime/items',
'/runtime/quests',
'/ws/health',
],
createRpgRuntimeAiAssistRoutes(context),
),
);
app.use(
'/api/runtime/custom-world/agent',
withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.creation.agent.api' }),
createCustomWorldAgentRoutes(context),
);
app.use(
express.static(context.config.publicDir, {

View File

@@ -32,6 +32,21 @@ function buildCookieParts(
return parts.join('; ');
}
function appendSetCookieHeader(response: Response, cookieValue: string) {
const currentHeader = response.getHeader('Set-Cookie');
if (!currentHeader) {
response.setHeader('Set-Cookie', cookieValue);
return;
}
if (Array.isArray(currentHeader)) {
response.setHeader('Set-Cookie', [...currentHeader, cookieValue]);
return;
}
response.setHeader('Set-Cookie', [String(currentHeader), cookieValue]);
}
export function hashRefreshSessionToken(token: string) {
return crypto.createHash('sha256').update(token).digest('hex');
}
@@ -46,8 +61,8 @@ export function setRefreshSessionCookie(
token: string,
maxAgeSeconds: number,
) {
response.setHeader(
'Set-Cookie',
appendSetCookieHeader(
response,
buildCookieParts(config, token, {
maxAgeSeconds,
}),
@@ -55,8 +70,8 @@ export function setRefreshSessionCookie(
}
export function clearRefreshSessionCookie(response: Response, config: AppConfig) {
response.setHeader(
'Set-Cookie',
appendSetCookieHeader(
response,
buildCookieParts(config, '', {
maxAgeSeconds: 0,
}),

View File

@@ -1,6 +0,0 @@
// Temporary bridge for legacy pure build calculation logic from src/**.
export { getEquipmentBonuses } from '../modules/runtime/runtimeEquipmentModule.js';
export {
getPlayerBuildDamageBreakdown,
resolvePlayerOutgoingDamageResult,
} from '../modules/runtime/runtimeBuildModule.js';

View File

@@ -1,8 +0,0 @@
// Temporary bridge for legacy pure runtime item resolution logic from src/**.
export {
buildLooseRuntimeItemGenerationContext,
buildQuestRuntimeItemGenerationContext,
buildDirectedRuntimeReward,
buildRuntimeInventoryStock,
flattenDirectedRuntimeRewardItems,
} from '../modules/runtime-item/runtimeItemModule.js';

View File

@@ -74,6 +74,11 @@ export type AppConfig = {
mockAvatarUrl: string;
};
authSession: {
accessCookieName: string;
accessCookieTtlSeconds: number;
accessCookieSecure: boolean;
accessCookieSameSite: 'Lax' | 'Strict' | 'None';
accessCookiePath: string;
refreshCookieName: string;
refreshSessionTtlDays: number;
refreshCookieSecure: boolean;
@@ -274,6 +279,11 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
'AUTH_REFRESH_COOKIE_SAME_SITE',
'Lax',
);
const accessSameSite = readString(
env,
'AUTH_ACCESS_COOKIE_SAME_SITE',
'Lax',
);
return {
nodeEnv,
@@ -484,6 +494,30 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
mockAvatarUrl: readString(env, 'WECHAT_MOCK_AVATAR_URL', ''),
},
authSession: {
accessCookieName: readString(
env,
'AUTH_ACCESS_COOKIE_NAME',
'genarrative_access_session',
),
accessCookieTtlSeconds: readPositiveInt(
env,
'AUTH_ACCESS_COOKIE_TTL_SECONDS',
7200,
),
accessCookieSecure: readBoolean(
env,
'AUTH_ACCESS_COOKIE_SECURE',
readString(env, 'NODE_ENV', 'development') === 'production',
),
accessCookieSameSite:
accessSameSite === 'None' || accessSameSite === 'Strict'
? (accessSameSite as AppConfig['authSession']['accessCookieSameSite'])
: 'Lax',
accessCookiePath: readString(
env,
'AUTH_ACCESS_COOKIE_PATH',
'/',
),
refreshCookieName: readString(
env,
'AUTH_REFRESH_COOKIE_NAME',

View File

@@ -5,6 +5,13 @@ import type { AppDatabase } from './db.js';
import { AuthAuditLogRepository } from './repositories/authAuditLogRepository.js';
import { AuthIdentityRepository } from './repositories/authIdentityRepository.js';
import { AuthRiskBlockRepository } from './repositories/authRiskBlockRepository.js';
import type { RpgAgentSessionRepository } from './repositories/RpgAgentSessionRepository.js';
import type { RpgSaveArchiveRepository } from './repositories/rpg-entry/RpgSaveArchiveRepository.js';
import type { RpgWorldLibraryRepository } from './repositories/rpg-entry/RpgWorldLibraryRepository.js';
import type { RpgBrowseHistoryRepository } from './repositories/rpg-profile/RpgBrowseHistoryRepository.js';
import type { RpgProfileDashboardRepository } from './repositories/rpg-profile/RpgProfileDashboardRepository.js';
import type { RpgRuntimeSnapshotRepository } from './repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js';
import type { RpgWorldProfileRepository } from './repositories/RpgWorldProfileRepository.js';
import { RuntimeRepository } from './repositories/runtimeRepository.js';
import { SmsAuthEventRepository } from './repositories/smsAuthEventRepository.js';
import { UserRepository } from './repositories/userRepository.js';
@@ -12,8 +19,8 @@ import { UserSessionRepository } from './repositories/userSessionRepository.js';
import { CaptchaChallengeStore } from './services/captchaChallengeStore.js';
import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js';
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
import { UpstreamLlmClient } from './services/llmClient.js';
import type { RpgWorldWorkSummaryService } from './services/RpgWorldWorkSummaryService.js';
import type { SmsVerificationService } from './services/smsVerificationService.js';
import type { WechatAuthService } from './services/wechatAuthService.js';
import { WechatAuthStateStore } from './services/wechatAuthStateStore.js';
@@ -28,11 +35,18 @@ export type AppContext = {
authRiskBlockRepository: AuthRiskBlockRepository;
smsAuthEventRepository: SmsAuthEventRepository;
userSessionRepository: UserSessionRepository;
rpgAgentSessionRepository: RpgAgentSessionRepository;
rpgWorldProfileRepository: RpgWorldProfileRepository;
rpgProfileDashboardRepository: RpgProfileDashboardRepository;
rpgBrowseHistoryRepository: RpgBrowseHistoryRepository;
rpgSaveArchiveRepository: RpgSaveArchiveRepository;
rpgWorldLibraryRepository: RpgWorldLibraryRepository;
rpgRuntimeSnapshotRepository: RpgRuntimeSnapshotRepository;
runtimeRepository: RuntimeRepository;
llmClient: UpstreamLlmClient;
customWorldSessions: CustomWorldSessionStore;
customWorldAgentSessions: CustomWorldAgentSessionStore;
customWorldAgentOrchestrator: CustomWorldAgentOrchestrator;
rpgWorldWorkSummaryService: RpgWorldWorkSummaryService;
smsVerificationService: SmsVerificationService;
wechatAuthService: WechatAuthService;
wechatAuthStates: WechatAuthStateStore;

View File

@@ -81,6 +81,11 @@ function createTestConfig(databaseUrl: string): AppConfig {
mockAvatarUrl: '',
},
authSession: {
accessCookieName: 'genarrative_access_session',
accessCookieTtlSeconds: 7200,
accessCookieSecure: false,
accessCookieSameSite: 'Lax',
accessCookiePath: '/',
refreshCookieName: 'genarrative_refresh_session',
refreshSessionTtlDays: 30,
refreshCookieSecure: false,

View File

@@ -9,7 +9,7 @@ import type {
type NpcChatTurnCompletionDirective,
NpcChatTurnRequest,
NpcRecruitDialogueRequest,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeChat.js';
import { parseLineListContent } from '../../../../packages/shared/src/llm/parsers.js';
import { prepareEventStreamResponse } from '../../http.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js';

View File

@@ -4,7 +4,7 @@ import test from 'node:test';
import type {
CharacterChatSuggestionsRequest,
NpcChatTurnRequest,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeChat.js';
import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js';
import {
generateCharacterChatSuggestionsFromOrchestrator,

View File

@@ -1,912 +0,0 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import http, {
type IncomingMessage,
type RequestOptions,
type ServerResponse,
} from 'node:http';
import https from 'node:https';
import path from 'node:path';
import { Router, type NextFunction, type Request, type Response } from 'express';
import type { AppConfig } from '../../config.js';
import { routeMeta } from '../../middleware/routeMeta.js';
const QWEN_SPRITE_MASTER_GENERATE_PATH = '/api/assets/qwen-sprite/master';
const QWEN_SPRITE_SHEET_GENERATE_PATH = '/api/assets/qwen-sprite/sheet';
const QWEN_SPRITE_FRAME_REPAIR_PATH = '/api/assets/qwen-sprite/frame-repair';
const QWEN_SPRITE_SAVE_PATH = '/api/assets/qwen-sprite/save';
const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1';
const DEFAULT_QWEN_IMAGE_MODEL = 'qwen-image-2.0';
function readJsonBody(req: IncomingMessage & { body?: unknown }) {
const parsedBody = req.body;
if (parsedBody && typeof parsedBody === 'object' && !Array.isArray(parsedBody)) {
return Promise.resolve(parsedBody as Record<string, unknown>);
}
return new Promise<Record<string, unknown>>((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
req.on('end', () => {
try {
const raw =
Buffer.concat(chunks)
.toString('utf8')
.replace(/^\uFEFF/u, '') || '{}';
resolve(JSON.parse(raw));
} catch (error) {
reject(error);
}
});
req.on('error', reject);
});
}
function sendJson(res: ServerResponse, statusCode: number, payload: unknown) {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(payload));
}
function isRecordValue(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function isStringArray(value: unknown): value is string[] {
return (
Array.isArray(value) &&
value.every((item) => typeof item === 'string' && item.trim().length > 0)
);
}
function resolveRuntimeEnv(config: AppConfig) {
return config.rawEnv;
}
function normalizeDashScopeBaseUrl(value: string) {
return value.replace(/\/$/u, '');
}
function extractApiErrorMessage(responseText: string, fallbackMessage: string) {
if (!responseText.trim()) {
return fallbackMessage;
}
try {
const parsed = JSON.parse(responseText) as {
code?: string;
message?: string;
error?: { message?: string };
};
if (
typeof parsed.error?.message === 'string' &&
parsed.error.message.trim()
) {
return parsed.error.message;
}
if (typeof parsed.message === 'string' && parsed.message.trim()) {
return parsed.message;
}
if (typeof parsed.code === 'string' && parsed.code.trim()) {
return `${fallbackMessage} (${parsed.code})`;
}
} catch {
// Fall through to raw text.
}
return responseText;
}
function sanitizePathSegment(value: string) {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-_]+/gu, '-')
.replace(/-+/gu, '-')
.replace(/^-|-$/gu, '');
return normalized || 'asset';
}
function createTimestampId(prefix: string) {
return `${prefix}-${Date.now()}`;
}
function requestTextResponse(
urlString: string,
options: {
method?: string;
headers?: Record<string, string>;
bodyText?: string;
} = {},
) {
return new Promise<{
statusCode: number;
headers: Record<string, string | string[] | undefined>;
bodyText: string;
}>((resolve, reject) => {
const url = new URL(urlString);
const transport = url.protocol === 'https:' ? https : http;
const payload = options.bodyText;
const requestOptions: RequestOptions = {
protocol: url.protocol,
hostname: url.hostname,
port: url.port ? Number(url.port) : undefined,
path: `${url.pathname}${url.search}`,
method: options.method ?? 'GET',
headers: {
...(options.headers ?? {}),
...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
},
};
const request = transport.request(requestOptions, (upstreamRes) => {
const chunks: Buffer[] = [];
upstreamRes.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
upstreamRes.on('end', () => {
resolve({
statusCode: upstreamRes.statusCode ?? 502,
headers: upstreamRes.headers,
bodyText: Buffer.concat(chunks).toString('utf8'),
});
});
upstreamRes.on('error', reject);
});
request.on('error', reject);
if (payload) {
request.write(payload);
}
request.end();
});
}
function requestBinaryResponse(
urlString: string,
options: {
method?: string;
headers?: Record<string, string>;
} = {},
) {
return new Promise<{
statusCode: number;
headers: Record<string, string | string[] | undefined>;
body: Buffer;
}>((resolve, reject) => {
const url = new URL(urlString);
const transport = url.protocol === 'https:' ? https : http;
const requestOptions: RequestOptions = {
protocol: url.protocol,
hostname: url.hostname,
port: url.port ? Number(url.port) : undefined,
path: `${url.pathname}${url.search}`,
method: options.method ?? 'GET',
headers: options.headers ?? {},
};
const request = transport.request(requestOptions, (upstreamRes) => {
const chunks: Buffer[] = [];
upstreamRes.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
upstreamRes.on('end', () => {
resolve({
statusCode: upstreamRes.statusCode ?? 502,
headers: upstreamRes.headers,
body: Buffer.concat(chunks),
});
});
upstreamRes.on('error', reject);
});
request.on('error', reject);
request.end();
});
}
function proxyJsonRequest(
urlString: string,
apiKey: string,
body: Record<string, unknown>,
) {
return requestTextResponse(urlString, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
bodyText: JSON.stringify(body),
});
}
function collectStringsByKey(
value: unknown,
targetKey: string,
results: string[],
) {
if (Array.isArray(value)) {
value.forEach((item) => collectStringsByKey(item, targetKey, results));
return;
}
if (!isRecordValue(value)) {
return;
}
const directValue = value[targetKey];
if (typeof directValue === 'string' && directValue.trim()) {
results.push(directValue.trim());
}
Object.values(value).forEach((nestedValue) =>
collectStringsByKey(nestedValue, targetKey, results),
);
}
function extractImageUrls(payload: Record<string, unknown>) {
const results: string[] = [];
collectStringsByKey(payload.output, 'image', results);
collectStringsByKey(payload.output, 'url', results);
return [...new Set(results)];
}
function parseDataUrl(source: string) {
const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source);
if (!matched) {
return null;
}
const mimeType = matched[1];
const base64Payload = matched[2];
const extension = (() => {
switch (mimeType) {
case 'image/jpeg':
return 'jpg';
case 'image/webp':
return 'webp';
default:
return 'png';
}
})();
return {
buffer: Buffer.from(base64Payload, 'base64'),
extension,
};
}
async function resolveImageSourcePayload(rootDir: string, source: string) {
const parsedDataUrl = parseDataUrl(source);
if (parsedDataUrl) {
return parsedDataUrl;
}
if (!source.startsWith('/')) {
throw new Error('图像来源必须是 Data URL 或 public 目录 URL。');
}
const normalizedSource = path.posix.normalize(source).replace(/^\/+/u, '');
const absolutePath = path.resolve(
rootDir,
'public',
...normalizedSource.split('/'),
);
const publicRoot = path.resolve(rootDir, 'public');
if (!absolutePath.startsWith(publicRoot)) {
throw new Error('图像来源路径越界。');
}
const buffer = await readFile(absolutePath);
const extension = path.extname(absolutePath).replace(/^\./u, '') || 'png';
return {
buffer,
extension,
};
}
async function resolveImageSourceAsDataUrl(rootDir: string, source: string) {
if (/^data:image\/[^;]+;base64,/u.test(source)) {
return source;
}
const payload = await resolveImageSourcePayload(rootDir, source);
const mimeType = (() => {
switch (payload.extension) {
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'webp':
return 'image/webp';
default:
return 'image/png';
}
})();
return `data:${mimeType};base64,${payload.buffer.toString('base64')}`;
}
async function writeDraftImageFile(
rootDir: string,
relativePath: string,
buffer: Buffer,
) {
const absolutePath = path.resolve(rootDir, 'public', ...relativePath.split('/'));
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, buffer);
return `/${relativePath}`;
}
async function generateQwenImages(
config: AppConfig,
input: {
kind: 'master' | 'sheet' | 'repair';
promptText: string;
negativePrompt: string;
model: string;
size: string;
promptExtend: boolean;
seed?: number;
candidateCount: number;
referenceImages: string[];
},
) {
const rootDir = config.projectRoot;
const runtimeEnv = resolveRuntimeEnv(config);
const baseUrl = normalizeDashScopeBaseUrl(
runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL,
);
const apiKey = runtimeEnv.DASHSCOPE_API_KEY || '';
if (!apiKey) {
throw new Error('服务端缺少 DASHSCOPE_API_KEY无法调用 Qwen-Image。');
}
const content = [
...(await Promise.all(
input.referenceImages
.slice(0, 3)
.map(async (image) => ({ image: await resolveImageSourceAsDataUrl(rootDir, image) })),
)),
{ text: input.promptText },
];
const requestPayload: Record<string, unknown> = {
model: input.model || DEFAULT_QWEN_IMAGE_MODEL,
input: {
messages: [
{
role: 'user',
content,
},
],
},
parameters: {
n: Math.max(1, Math.min(6, input.candidateCount)),
negative_prompt: input.negativePrompt,
prompt_extend: input.promptExtend,
watermark: false,
size: input.size,
...(typeof input.seed === 'number' && Number.isFinite(input.seed)
? { seed: input.seed }
: {}),
},
};
const response = await proxyJsonRequest(
`${baseUrl}/services/aigc/multimodal-generation/generation`,
apiKey,
requestPayload,
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw new Error(
extractApiErrorMessage(response.bodyText, 'Qwen-Image 生成失败。'),
);
}
const parsed = JSON.parse(response.bodyText) as Record<string, unknown>;
const imageUrls = extractImageUrls(parsed);
if (imageUrls.length === 0) {
throw new Error('Qwen-Image 未返回可下载的图片结果。');
}
const draftId = createTimestampId(`qwen-${input.kind}`);
const relativeDir = path.posix.join(
'generated-qwen-sprites',
'_drafts',
input.kind,
draftId,
);
const drafts = await Promise.all(
imageUrls.map(async (imageUrl, index) => {
const binaryResponse = await requestBinaryResponse(imageUrl);
if (
binaryResponse.statusCode < 200 ||
binaryResponse.statusCode >= 300
) {
throw new Error(`下载生成图片失败(${binaryResponse.statusCode})。`);
}
const imageSrc = await writeDraftImageFile(
rootDir,
path.posix.join(relativeDir, `candidate-${String(index + 1).padStart(2, '0')}.png`),
binaryResponse.body,
);
return {
id: `${draftId}-${index + 1}`,
label: `${input.kind === 'master' ? '主图' : input.kind === 'sheet' ? '精灵表' : '修帧'} ${index + 1}`,
imageSrc,
remoteUrl: imageUrl,
};
}),
);
await writeFile(
path.resolve(rootDir, 'public', ...relativeDir.split('/'), 'job.json'),
JSON.stringify(
{
draftId,
kind: input.kind,
model: input.model,
size: input.size,
promptText: input.promptText,
negativePrompt: input.negativePrompt,
promptExtend: input.promptExtend,
seed: input.seed,
candidateCount: input.candidateCount,
referenceImageCount: input.referenceImages.length,
drafts,
createdAt: new Date().toISOString(),
},
null,
2,
) + '\n',
'utf8',
);
return {
draftId,
drafts,
model: input.model,
size: input.size,
promptText: input.promptText,
negativePrompt: input.negativePrompt,
};
}
async function handleGenerateMaster(
config: AppConfig,
req: IncomingMessage & { body?: unknown },
res: ServerResponse,
) {
if (req.method !== 'POST') {
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
return;
}
let body: Record<string, unknown>;
try {
body = await readJsonBody(req);
} catch {
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
return;
}
const promptText =
typeof body.promptText === 'string' ? body.promptText.trim() : '';
const negativePrompt =
typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : '';
const model =
typeof body.model === 'string' && body.model.trim()
? body.model.trim()
: DEFAULT_QWEN_IMAGE_MODEL;
const size =
typeof body.size === 'string' && body.size.trim()
? body.size.trim()
: '1024*1024';
const promptExtend = body.promptExtend !== false;
const candidateCount =
typeof body.candidateCount === 'number' && Number.isFinite(body.candidateCount)
? body.candidateCount
: 1;
const seed =
typeof body.seed === 'number' && Number.isFinite(body.seed)
? body.seed
: undefined;
const referenceImages = isStringArray(body.referenceImages)
? body.referenceImages
: [];
if (!promptText) {
sendJson(res, 400, { error: { message: 'promptText is required.' } });
return;
}
try {
const result = await generateQwenImages(config, {
kind: 'master',
promptText,
negativePrompt,
model,
size,
promptExtend,
seed,
candidateCount,
referenceImages,
});
sendJson(res, 200, {
ok: true,
...result,
});
} catch (error) {
sendJson(res, 500, {
error: {
message: error instanceof Error ? error.message : '生成主图失败。',
},
});
}
}
async function handleGenerateSheet(
config: AppConfig,
req: IncomingMessage & { body?: unknown },
res: ServerResponse,
) {
if (req.method !== 'POST') {
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
return;
}
let body: Record<string, unknown>;
try {
body = await readJsonBody(req);
} catch {
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
return;
}
const promptText =
typeof body.promptText === 'string' ? body.promptText.trim() : '';
const negativePrompt =
typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : '';
const model =
typeof body.model === 'string' && body.model.trim()
? body.model.trim()
: DEFAULT_QWEN_IMAGE_MODEL;
const size =
typeof body.size === 'string' && body.size.trim()
? body.size.trim()
: '1024*1024';
const promptExtend = body.promptExtend !== false;
const candidateCount =
typeof body.candidateCount === 'number' && Number.isFinite(body.candidateCount)
? body.candidateCount
: 1;
const seed =
typeof body.seed === 'number' && Number.isFinite(body.seed)
? body.seed
: undefined;
const referenceImages = isStringArray(body.referenceImages)
? body.referenceImages
: [];
if (!promptText) {
sendJson(res, 400, { error: { message: 'promptText is required.' } });
return;
}
try {
const result = await generateQwenImages(config, {
kind: 'sheet',
promptText,
negativePrompt,
model,
size,
promptExtend,
seed,
candidateCount,
referenceImages,
});
sendJson(res, 200, {
ok: true,
...result,
});
} catch (error) {
sendJson(res, 500, {
error: {
message: error instanceof Error ? error.message : '生成精灵表失败。',
},
});
}
}
async function handleRepairFrame(
config: AppConfig,
req: IncomingMessage & { body?: unknown },
res: ServerResponse,
) {
if (req.method !== 'POST') {
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
return;
}
let body: Record<string, unknown>;
try {
body = await readJsonBody(req);
} catch {
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
return;
}
const promptText =
typeof body.promptText === 'string' ? body.promptText.trim() : '';
const negativePrompt =
typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : '';
const model =
typeof body.model === 'string' && body.model.trim()
? body.model.trim()
: DEFAULT_QWEN_IMAGE_MODEL;
const size =
typeof body.size === 'string' && body.size.trim()
? body.size.trim()
: '512*512';
const promptExtend = body.promptExtend !== false;
const seed =
typeof body.seed === 'number' && Number.isFinite(body.seed)
? body.seed
: undefined;
const referenceImages = isStringArray(body.referenceImages)
? body.referenceImages
: [];
if (!promptText) {
sendJson(res, 400, { error: { message: 'promptText is required.' } });
return;
}
if (referenceImages.length === 0) {
sendJson(res, 400, {
error: { message: '至少需要一张参考图来修复帧。' },
});
return;
}
try {
const result = await generateQwenImages(config, {
kind: 'repair',
promptText,
negativePrompt,
model,
size,
promptExtend,
seed,
candidateCount: 1,
referenceImages,
});
sendJson(res, 200, {
ok: true,
...result,
repairedFrame: result.drafts[0] ?? null,
});
} catch (error) {
sendJson(res, 500, {
error: {
message: error instanceof Error ? error.message : '修帧失败。',
},
});
}
}
async function handleSaveAsset(
rootDir: string,
req: IncomingMessage & { body?: unknown },
res: ServerResponse,
) {
if (req.method !== 'POST') {
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
return;
}
let body: Record<string, unknown>;
try {
body = await readJsonBody(req);
} catch {
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
return;
}
const assetKey =
typeof body.assetKey === 'string' ? sanitizePathSegment(body.assetKey) : '';
const actionKey =
typeof body.actionKey === 'string' ? sanitizePathSegment(body.actionKey) : '';
const masterSource =
typeof body.masterSource === 'string' ? body.masterSource.trim() : '';
const sheetSource =
typeof body.sheetSource === 'string' ? body.sheetSource.trim() : '';
const framesDataUrls = isStringArray(body.framesDataUrls)
? body.framesDataUrls
: [];
const metadata = isRecordValue(body.metadata) ? body.metadata : {};
const prompts = isRecordValue(body.prompts) ? body.prompts : {};
if (!assetKey) {
sendJson(res, 400, { error: { message: 'assetKey is required.' } });
return;
}
if (!actionKey) {
sendJson(res, 400, { error: { message: 'actionKey is required.' } });
return;
}
if (!sheetSource) {
sendJson(res, 400, { error: { message: 'sheetSource is required.' } });
return;
}
try {
const assetId = createTimestampId('qwen-sprite');
const relativeDir = path.posix.join(
'generated-qwen-sprites',
assetKey,
actionKey,
assetId,
);
const absoluteDir = path.resolve(rootDir, 'public', ...relativeDir.split('/'));
await mkdir(path.join(absoluteDir, 'frames'), { recursive: true });
let masterImagePath: string | null = null;
if (masterSource) {
const payload = await resolveImageSourcePayload(rootDir, masterSource);
masterImagePath = await writeDraftImageFile(
rootDir,
path.posix.join(relativeDir, `master.${payload.extension}`),
payload.buffer,
);
}
const sheetPayload = await resolveImageSourcePayload(rootDir, sheetSource);
const sheetImagePath = await writeDraftImageFile(
rootDir,
path.posix.join(relativeDir, `sheet.${sheetPayload.extension}`),
sheetPayload.buffer,
);
const framePaths: string[] = [];
for (let index = 0; index < framesDataUrls.length; index += 1) {
const framePayload = await resolveImageSourcePayload(
rootDir,
framesDataUrls[index] ?? '',
);
const framePath = await writeDraftImageFile(
rootDir,
path.posix.join(
relativeDir,
'frames',
`frame-${String(index + 1).padStart(2, '0')}.${framePayload.extension}`,
),
framePayload.buffer,
);
framePaths.push(framePath);
}
await writeFile(
path.join(absoluteDir, 'metadata.json'),
JSON.stringify(
{
assetId,
assetKey,
actionKey,
masterImagePath,
sheetImagePath,
framePaths,
metadata,
prompts,
createdAt: new Date().toISOString(),
},
null,
2,
) + '\n',
'utf8',
);
sendJson(res, 200, {
ok: true,
assetId,
assetDir: `/${relativeDir}`,
masterImagePath,
sheetImagePath,
framePaths,
saveMessage: '已保存到 public/generated-qwen-sprites。',
});
} catch (error) {
sendJson(res, 500, {
error: {
message: error instanceof Error ? error.message : '保存精灵表资产失败。',
},
});
}
}
function toExpressHandler(
handler: (
request: IncomingMessage & { body?: unknown },
response: ServerResponse,
) => Promise<void> | void,
) {
return (request: Request, response: Response, next: NextFunction) => {
Promise.resolve(
handler(
request as Request & IncomingMessage & { body?: unknown },
response as Response & ServerResponse,
),
).catch(next);
};
}
export function createQwenSpriteRoutes(config: AppConfig) {
const router = Router();
router.use((request, response, next) => {
if (
request.path !== '/api/assets' &&
!request.path.startsWith('/api/assets/')
) {
next();
return;
}
if (!config.assetsApiEnabled) {
response.status(403).json({
error: {
message: '资产工具接口当前未启用。',
},
});
return;
}
next();
});
router.post(
QWEN_SPRITE_MASTER_GENERATE_PATH,
routeMeta({ operation: 'assets.qwenSprite.master.generate' }),
toExpressHandler((request, response) =>
handleGenerateMaster(config, request, response),
),
);
router.post(
QWEN_SPRITE_SHEET_GENERATE_PATH,
routeMeta({ operation: 'assets.qwenSprite.sheet.generate' }),
toExpressHandler((request, response) =>
handleGenerateSheet(config, request, response),
),
);
router.post(
QWEN_SPRITE_FRAME_REPAIR_PATH,
routeMeta({ operation: 'assets.qwenSprite.frameRepair.generate' }),
toExpressHandler((request, response) =>
handleRepairFrame(config, request, response),
),
);
router.post(
QWEN_SPRITE_SAVE_PATH,
routeMeta({ operation: 'assets.qwenSprite.asset.save' }),
toExpressHandler((request, response) =>
handleSaveAsset(config.projectRoot, request, response),
),
);
return router;
}

View File

@@ -1,8 +1,10 @@
import type {
RuntimeBattlePresentation,
RuntimeStoryChoicePayload,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import type {
RuntimeStoryChoicePayload,
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryAction.js';
import {
buildInventoryUseResultText,
incrementGameRuntimeStats,
@@ -26,7 +28,7 @@ import {
getPlayerSkillCooldowns,
setEncounterNpcState,
type RuntimeSession,
} from '../story/runtimeSession.js';
} from '../rpg-runtime-story/RpgRuntimeSessionPrimitives.js';
type CombatActionConfig = {
actionText: string;

View File

@@ -0,0 +1,365 @@
import type {
AttributeVector,
CustomWorldNpc,
CustomWorldPlayableNpc,
RoleAttributeProfile,
WorldAttributeSchema,
WorldAttributeSlot,
WorldType,
} from '../runtimeTypes.js';
import { inferWorldTypeFromSetting } from './creatorIntentBridge.js';
import { slugify } from './normalizeShared.js';
/**
* 工作包 G
* 把 attribute schema 构建和角色属性画像编译从主 runtime compiler 中抽离,
* 让结果预览编译、世界基础 profile 归一和角色属性推导有清晰边界。
*/
const WORLD_ATTRIBUTE_SLOT_IDS = [
'axis_a',
'axis_b',
'axis_c',
'axis_d',
'axis_e',
'axis_f',
] as const;
const AXIS_KEYWORD_RULES: Array<{
slotId: string;
patterns: RegExp[];
weight: number;
}> = [
{ slotId: 'axis_a', patterns: [/||||||||||/u], weight: 16 },
{ slotId: 'axis_b', patterns: [/|||||||||/u], weight: 16 },
{ slotId: 'axis_c', patterns: [/|||||||||/u], weight: 16 },
{ slotId: 'axis_d', patterns: [/|||||||||/u], weight: 16 },
{ slotId: 'axis_e', patterns: [/|||||||||/u], weight: 16 },
{ slotId: 'axis_f', patterns: [/|||||||||/u], weight: 16 },
];
export function buildTemplateWorldAttributeSchema(
worldType: Exclude<WorldType, 'CUSTOM'>,
) {
const common = {
schemaVersion: 1,
generatedFrom:
worldType === 'XIANXIA'
? {
worldType: 'XIANXIA' as const,
worldName: '仙侠',
settingSummary: '灵潮、宗门、禁制、秘境与道途交织。',
tone: '空灵、危险、带着灾变与大道压迫。',
conflictCore: '在裂变与因果之间稳住自我与道途。',
}
: {
worldType: 'WUXIA' as const,
worldName: '武侠',
settingSummary: '江湖、门派、旧案与人情纠葛并存。',
tone: '克制、紧张、讲究局势与心气。',
conflictCore: '在人情、威压与旧案之间立住自身。',
},
};
if (worldType === 'XIANXIA') {
return {
id: 'schema:xianxia:v1',
worldId: 'XIANXIA',
schemaName: '灵界六轴',
...common,
slots: [
{
slotId: 'axis_a',
name: '道骨',
definition: '承载道压与高强度冲击的底子。',
positiveSignals: ['承压', '根基稳', '扛得住'],
negativeSignals: ['根基浅', '易溃', '承载不足'],
combatUseText: '扛住灵压、正面承受高强度对撞。',
socialUseText: '让人感到根基扎实,值得托付重事。',
explorationUseText: '承受秘境、禁制与裂隙带来的压迫。',
},
{
slotId: 'axis_b',
name: '灵行',
definition: '位移、御空、转场、抢占天时地利的能力。',
positiveSignals: ['位移', '御空', '机动'],
negativeSignals: ['迟滞', '失位', '转场慢'],
combatUseText: '抢位、御空、快速重整战场位置。',
socialUseText: '反应轻快,擅长顺势接住局面的变化。',
explorationUseText: '穿越高危地形、裂隙、云海与复杂禁区。',
},
{
slotId: 'axis_c',
name: '识海',
definition: '解析禁制、洞察因果、识破虚实的能力。',
positiveSignals: ['洞察', '解构', '看破'],
negativeSignals: ['迷失', '误判', '看不清'],
combatUseText: '识破术理、找出因果节点与破绽。',
socialUseText: '更容易辨认真话、虚言与隐藏动机。',
explorationUseText: '解读阵纹、禁制、旧史与环境异象。',
},
{
slotId: 'axis_d',
name: '劫纹',
definition: '在高危变化中强行推进、改写局势的能力。',
positiveSignals: ['强推', '决断', '逆转'],
negativeSignals: ['畏缩', '迟疑', '不敢碰变局'],
combatUseText: '在高压窗口里压上去,逼出变化与突破。',
socialUseText: '在关键谈判中拍板,推动他人表态。',
explorationUseText: '面对异变与风险时敢于推进关键节点。',
},
{
slotId: 'axis_e',
name: '心契',
definition: '与他者、器物、灵兽、誓约建立共鸣的能力。',
positiveSignals: ['共鸣', '结契', '安抚'],
negativeSignals: ['隔阂', '生硬', '难以共振'],
combatUseText: '与器物、灵兽、同伴形成协同与共鸣。',
socialUseText: '建立信任、誓约与更深层的关系连结。',
explorationUseText: '借由共鸣打开封印、回应遗物或安抚异兽。',
},
{
slotId: 'axis_f',
name: '玄息',
definition: '循环灵息、稳住心神、让自身持续在线的能力。',
positiveSignals: ['稳态', '回转', '续航'],
negativeSignals: ['紊乱', '枯竭', '失衡'],
combatUseText: '维持灵息循环、拖住长线压力与消耗。',
socialUseText: '气息沉稳,不轻易乱阵脚或露出破绽。',
explorationUseText: '在漫长探索与灵潮侵蚀中维持可行动状态。',
},
] satisfies WorldAttributeSlot[],
} satisfies WorldAttributeSchema;
}
return {
id: 'schema:wuxia:v1',
worldId: 'WUXIA',
schemaVersion: 1,
schemaName: '江湖六脉',
generatedFrom: common.generatedFrom,
slots: [
{
slotId: 'axis_a',
name: '骨势',
definition: '扛压、顶冲、硬吃风险也不退的势头。',
positiveSignals: ['扛压', '硬桥硬马', '稳住正面'],
negativeSignals: ['虚浮', '怯退', '一碰就散'],
combatUseText: '顶住正面压力、换伤不退、撑住阵线。',
socialUseText: '在强压场面里不露怯,给人可靠或强硬之感。',
explorationUseText: '穿越险路、硬顶机关、承受高压环境。',
},
{
slotId: 'axis_b',
name: '身法',
definition: '腾挪、抢位、换线、把握出手节奏的能力。',
positiveSignals: ['快', '轻灵', '抢位'],
negativeSignals: ['迟缓', '失位', '笨重'],
combatUseText: '切线换位、闪转腾挪、争夺先手。',
socialUseText: '应变快,擅长观察气口并顺势接话。',
explorationUseText: '攀越、潜入、追踪与复杂地形穿行。',
},
{
slotId: 'axis_c',
name: '眼脉',
definition: '看破破绽、拆招、识局、看穿人心的能力。',
positiveSignals: ['识局', '洞察', '拆招'],
negativeSignals: ['迟钝', '误判', '看不透'],
combatUseText: '抓破绽、拆套路、找出最该切入的位置。',
socialUseText: '判断弦外之音、试探真假、识别来意。',
explorationUseText: '识破机关、辨认痕迹、看懂异状。',
},
{
slotId: 'axis_d',
name: '心焰',
definition: '决断、压迫、胆气、在局面中立住自身意志的能力。',
positiveSignals: ['胆气', '决断', '压迫'],
negativeSignals: ['犹疑', '软弱', '易被动摇'],
combatUseText: '逼迫对手、强行推进、在关键时刻拍板。',
socialUseText: '立威、定调、在谈判里压住场子。',
explorationUseText: '在未知风险前保持决断,不被局势拖死。',
},
{
slotId: 'axis_e',
name: '尘缘',
definition: '与人事、情面、承诺、牵引关系打交道的能力。',
positiveSignals: ['通人情', '会安抚', '懂交换'],
negativeSignals: ['生硬', '失礼', '不近人情'],
combatUseText: '借势协同、读懂同伴与对手的关系脉络。',
socialUseText: '安抚、求助、结盟、维系承诺与信任。',
explorationUseText: '从传闻、人脉和地方关系里打开线索。',
},
{
slotId: 'axis_f',
name: '玄息',
definition: '调息、稳态、久战、把自身维持在可用状态的能力。',
positiveSignals: ['稳', '续战', '调息'],
negativeSignals: ['紊乱', '易崩', '续不上'],
combatUseText: '续战、回气、稳住节奏与状态。',
socialUseText: '遇事不乱,语气和姿态都更沉稳可信。',
explorationUseText: '长线跋涉、在恶劣环境下维持专注与状态。',
},
] satisfies WorldAttributeSlot[],
} satisfies WorldAttributeSchema;
}
export function generateWorldAttributeSchema(input: {
worldName: string;
settingText: string;
summary: string;
tone: string;
playerGoal: string;
}) {
const inferredWorldType = inferWorldTypeFromSetting(input.settingText);
const template = buildTemplateWorldAttributeSchema(
inferredWorldType === 'XIANXIA' ? 'XIANXIA' : 'WUXIA',
);
return {
...template,
id: `schema:custom:${slugify(input.worldName)}`,
worldId: `custom:${input.worldName}`,
generatedFrom: {
worldType: 'CUSTOM',
worldName: input.worldName,
settingSummary: input.summary,
tone: input.tone,
conflictCore: input.playerGoal,
},
} satisfies WorldAttributeSchema;
}
function normalizeAttributeValues(
values: AttributeVector,
slotIds: readonly string[],
targetTotal = 360,
) {
const positiveValues = slotIds.map((slotId) => Math.max(0, values[slotId] ?? 0));
const rawTotal = positiveValues.reduce((sum, value) => sum + value, 0);
const normalized =
rawTotal > 0
? positiveValues.map((value) => (value / rawTotal) * targetTotal)
: slotIds.map(() => targetTotal / Math.max(slotIds.length, 1));
const rounded = normalized.map((value) => Math.max(0, Math.min(100, Math.round(value))));
return Object.fromEntries(
slotIds.map((slotId, index) => [slotId, rounded[index] ?? 0]),
) as AttributeVector;
}
function ensureRoleAttributeProfile(
profile: Partial<RoleAttributeProfile> | null | undefined,
schema: WorldAttributeSchema,
fallbackValues: AttributeVector,
): RoleAttributeProfile {
const slotIds = schema.slots.map((slot) => slot.slotId);
const values = normalizeAttributeValues(
{
...fallbackValues,
...(profile?.values ?? {}),
},
slotIds,
);
const sortedSlots = [...schema.slots]
.map((slot) => ({
slot,
value: values[slot.slotId] ?? 0,
}))
.sort((left, right) => right.value - left.value);
return {
schemaId: profile?.schemaId ?? schema.id,
values,
topTraits: sortedSlots.slice(0, 2).map((entry) => entry.slot.name),
hiddenTraits: profile?.hiddenTraits ? [...profile.hiddenTraits] : undefined,
evidence:
profile?.evidence?.length
? [...profile.evidence]
: sortedSlots.slice(0, 3).map((entry) => ({
slotId: entry.slot.slotId,
reason: `${entry.slot.name}在当前画像中最突出。`,
})),
};
}
function buildDefaultAxisVector(
overrides: Partial<Record<(typeof WORLD_ATTRIBUTE_SLOT_IDS)[number], number>>,
) {
return WORLD_ATTRIBUTE_SLOT_IDS.reduce<AttributeVector>((result, slotId) => {
result[slotId] = overrides[slotId] ?? 0;
return result;
}, {});
}
function buildRoleAttributeProfileFromTexts(params: {
schema: WorldAttributeSchema;
textBlocks: Array<string | null | undefined>;
}) {
const sourceText = params.textBlocks.filter(Boolean).join(' ');
const seed = buildDefaultAxisVector({
axis_a: 58,
axis_b: 58,
axis_c: 58,
axis_d: 58,
axis_e: 58,
axis_f: 58,
});
AXIS_KEYWORD_RULES.forEach((rule) => {
const matches = rule.patterns.reduce(
(count, pattern) => count + (pattern.test(sourceText) ? 1 : 0),
0,
);
if (matches <= 0) {
return;
}
seed[rule.slotId] = (seed[rule.slotId] ?? 0) + rule.weight * matches;
});
return ensureRoleAttributeProfile(
{
schemaId: params.schema.id,
},
params.schema,
seed,
);
}
export function buildCustomWorldPlayableNpcAttributeProfile(
npc: CustomWorldPlayableNpc,
schema: WorldAttributeSchema,
) {
return buildRoleAttributeProfileFromTexts({
schema,
textBlocks: [
npc.title,
npc.role,
npc.description,
npc.backstory,
npc.personality,
npc.motivation,
npc.combatStyle,
...(npc.relationshipHooks ?? []),
...(npc.tags ?? []),
],
});
}
export function buildCustomWorldStoryNpcAttributeProfile(
npc: CustomWorldNpc,
schema: WorldAttributeSchema,
) {
return buildRoleAttributeProfileFromTexts({
schema,
textBlocks: [
npc.title,
npc.role,
npc.description,
npc.backstory,
npc.personality,
npc.motivation,
npc.combatStyle,
...(npc.relationshipHooks ?? []),
...(npc.tags ?? []),
],
});
}

View File

@@ -0,0 +1,410 @@
import type {
CustomWorldGenerationFramework,
CustomWorldProfile,
} from '../runtimeTypes.js';
import {
buildCustomWorldPlayableNpcAttributeProfile,
buildCustomWorldStoryNpcAttributeProfile,
generateWorldAttributeSchema,
} from './buildAttributeSchema.js';
import {
buildWorldName,
inferWorldTypeFromSetting,
normalizeWorldType,
normalizeCustomWorldCreatorIntent,
normalizeCustomWorldLockState,
resolveCustomWorldRuntimeIntentBridge,
} from './creatorIntentBridge.js';
import {
buildFallbackCustomWorldCampScene,
normalizeCampOutline,
normalizeCampScene,
} from './normalizeCamp.js';
import {
buildCustomWorldRawProfileLandmarksFromFramework,
normalizeLandmarkOutlineList,
normalizeLandmarks,
} from './normalizeLandmark.js';
import {
buildCustomWorldRawProfileRolesFromFramework,
normalizeCustomWorldGenerationFrameworkRoles,
normalizePlayableNpcList,
normalizeStoryNpcList,
} from './normalizeRole.js';
import {
buildDefaultCustomWorldCover,
MIN_CUSTOM_WORLD_LANDMARK_COUNT,
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
normalizeCustomWorldCover,
normalizeItemList,
normalizeTags,
PLAYABLE_TEMPLATE_CHARACTER_IDS,
slugify,
toRecordArray,
toText,
} from './normalizeShared.js';
import { normalizeSceneChapterBlueprints } from './normalizeSceneChapter.js';
/**
* 工作包 G
* 让 runtime profile 真正由“主编译入口 + 目录化 normalize/build 子模块”组成。
*/
function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
const templateWorldType = inferWorldTypeFromSetting(settingText);
const name = buildWorldName(settingText, templateWorldType);
const subtitle = '前路未明';
const summary = settingText.trim()
? `这个世界围绕“${settingText.trim().slice(0, 28)}”展开。`
: '一个仍待展开的独立世界正在成形。';
const tone = '未知、紧绷、仍在展开';
const playerGoal = '查清眼前局势的关键矛盾,并守住仍值得相信的人与事';
const camp = buildFallbackCustomWorldCampScene({
name,
summary,
tone,
playerGoal,
settingText: settingText.trim(),
});
return {
id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`,
settingText: settingText.trim(),
name,
subtitle,
summary,
tone,
playerGoal,
cover: buildDefaultCustomWorldCover([]),
templateWorldType,
compatibilityTemplateWorldType: templateWorldType,
majorFactions: [],
coreConflicts: [summary],
attributeSchema: generateWorldAttributeSchema({
worldName: name,
settingText: settingText.trim(),
summary,
tone,
playerGoal,
}),
playableNpcs: [],
storyNpcs: [],
items: [],
camp,
landmarks: [],
themePack: null,
storyGraph: null,
creatorIntent: null,
anchorPack: null,
lockState: normalizeCustomWorldLockState(null),
generationMode: 'full',
generationStatus: 'complete',
ownedSettingLayers: null,
scenarioPackId: null,
campaignPackId: null,
};
}
export function normalizeCustomWorldGenerationFramework(
raw: unknown,
settingText: string,
): CustomWorldGenerationFramework {
const fallback = buildBaseCustomWorldProfile(settingText);
if (!raw || typeof raw !== 'object') {
return {
settingText: fallback.settingText,
name: fallback.name,
subtitle: fallback.subtitle,
summary: fallback.summary,
tone: fallback.tone,
playerGoal: fallback.playerGoal,
templateWorldType: fallback.templateWorldType,
compatibilityTemplateWorldType:
fallback.compatibilityTemplateWorldType ?? fallback.templateWorldType,
majorFactions: [],
coreConflicts: [fallback.summary],
camp: {
name: fallback.camp?.name ?? '归舍',
description: fallback.camp?.description ?? '',
dangerLevel: fallback.camp?.dangerLevel ?? 'low',
},
playableNpcs: [],
storyNpcs: [],
landmarks: [],
};
}
const item = raw as Record<string, unknown>;
const roleState = normalizeCustomWorldGenerationFrameworkRoles({
raw: item,
fallback,
settingText,
});
return {
settingText: settingText.trim(),
name: roleState.name,
subtitle: toText(item.subtitle) || fallback.subtitle,
summary: toText(item.summary) || fallback.summary,
tone: toText(item.tone) || fallback.tone,
playerGoal: toText(item.playerGoal) || fallback.playerGoal,
templateWorldType: roleState.templateWorldType,
compatibilityTemplateWorldType: roleState.templateWorldType,
majorFactions: normalizeTags(item.majorFactions, []),
coreConflicts: normalizeTags(item.coreConflicts, [fallback.summary]),
camp: {
name: normalizeCampOutline(item.camp, roleState.campFallbackProfile).name,
description: normalizeCampOutline(item.camp, roleState.campFallbackProfile)
.description,
dangerLevel: normalizeCampOutline(item.camp, roleState.campFallbackProfile)
.dangerLevel,
},
playableNpcs: roleState.playableNpcs,
storyNpcs: roleState.storyNpcs,
landmarks: normalizeLandmarkOutlineList(item.landmarks),
};
}
export function buildCustomWorldRawProfileFromFramework(
framework: CustomWorldGenerationFramework,
) {
return {
name: framework.name,
subtitle: framework.subtitle,
summary: framework.summary,
tone: framework.tone,
playerGoal: framework.playerGoal,
templateWorldType: framework.templateWorldType,
compatibilityTemplateWorldType: framework.compatibilityTemplateWorldType,
majorFactions: framework.majorFactions,
coreConflicts: framework.coreConflicts,
camp: {
name: framework.camp.name,
description: framework.camp.description,
dangerLevel: framework.camp.dangerLevel,
},
...buildCustomWorldRawProfileRolesFromFramework(framework),
landmarks: buildCustomWorldRawProfileLandmarksFromFramework(framework),
};
}
function pickCyclic<T>(items: readonly T[], index: number, label: string): T {
const item = items[index % items.length];
if (item === undefined) {
throw new Error(`Missing ${label}`);
}
return item;
}
export function normalizeCustomWorldProfile(
raw: unknown,
settingText: string,
): CustomWorldProfile {
const fallback = buildBaseCustomWorldProfile(settingText);
if (!raw || typeof raw !== 'object') {
return fallback;
}
const item = raw as Record<string, unknown>;
const worldSignalText = [
settingText,
toText(item.subtitle),
toText(item.summary),
toText(item.tone),
toText(item.playerGoal),
].join(' ');
const templateWorldType = normalizeWorldType(
item.templateWorldType,
worldSignalText,
);
const name =
toText(item.name) || buildWorldName(settingText, templateWorldType);
const summary = toText(item.summary) || fallback.summary;
const tone = toText(item.tone) || fallback.tone;
const playerGoal = toText(item.playerGoal) || fallback.playerGoal;
const generatedAttributeSchema = generateWorldAttributeSchema({
worldName: name,
settingText: settingText.trim(),
summary,
tone,
playerGoal,
});
const playableNpcs = normalizePlayableNpcList(item.playableNpcs);
const storyNpcs = normalizeStoryNpcList(item.storyNpcs);
const landmarkDrafts = toRecordArray(item.landmarks);
const camp = normalizeCampScene(item.camp, {
name,
summary,
tone,
playerGoal,
settingText: settingText.trim(),
});
const runtimeBridge = resolveCustomWorldRuntimeIntentBridge(item);
return {
id:
toText(item.id) || `custom-world-${Date.now().toString(36)}-${slugify(name)}`,
settingText: settingText.trim(),
name,
subtitle: toText(item.subtitle) || fallback.subtitle,
summary,
tone,
playerGoal,
cover: normalizeCustomWorldCover(item.cover, playableNpcs),
templateWorldType,
compatibilityTemplateWorldType: templateWorldType,
majorFactions: normalizeTags(item.majorFactions, []),
coreConflicts: normalizeTags(item.coreConflicts, [summary]),
attributeSchema:
item.attributeSchema && typeof item.attributeSchema === 'object'
? generatedAttributeSchema
: generatedAttributeSchema,
playableNpcs,
storyNpcs,
items: normalizeItemList(item.items),
camp,
landmarks: normalizeLandmarks({
landmarks: landmarkDrafts,
storyNpcs,
}),
themePack:
item.themePack && typeof item.themePack === 'object'
? (item.themePack as CustomWorldProfile['themePack'])
: null,
storyGraph:
item.storyGraph && typeof item.storyGraph === 'object'
? (item.storyGraph as CustomWorldProfile['storyGraph'])
: null,
anchorContent:
item.anchorContent && typeof item.anchorContent === 'object'
? (item.anchorContent as Record<string, unknown>)
: null,
creatorIntent: runtimeBridge.creatorIntent,
anchorPack: runtimeBridge.anchorPack,
lockState: runtimeBridge.lockState,
generationMode:
item.generationMode === 'fast' || item.generationMode === 'full'
? item.generationMode
: fallback.generationMode,
generationStatus:
item.generationStatus === 'key_only' || item.generationStatus === 'complete'
? item.generationStatus
: fallback.generationStatus,
ownedSettingLayers:
item.ownedSettingLayers && typeof item.ownedSettingLayers === 'object'
? (item.ownedSettingLayers as Record<string, unknown>)
: null,
knowledgeFacts:
Array.isArray(item.knowledgeFacts)
? (item.knowledgeFacts as Array<Record<string, unknown>>)
: null,
threadContracts:
Array.isArray(item.threadContracts)
? (item.threadContracts as Array<Record<string, unknown>>)
: null,
sceneChapterBlueprints: normalizeSceneChapterBlueprints(
item.sceneChapterBlueprints,
),
scenarioPackId: toText(item.scenarioPackId) || null,
campaignPackId: toText(item.campaignPackId) || null,
};
}
export function buildCompiledCustomWorldProfile(
raw: unknown,
settingText: string,
): CustomWorldProfile {
const profile = normalizeCustomWorldProfile(raw, settingText);
const playableNpcs = profile.playableNpcs.map((npc, index) => {
const templateCharacterId =
npc.templateCharacterId ??
pickCyclic(
PLAYABLE_TEMPLATE_CHARACTER_IDS,
index,
'playable template character id',
);
return {
...npc,
templateCharacterId,
attributeProfile:
npc.attributeProfile ??
buildCustomWorldPlayableNpcAttributeProfile(
{
...npc,
templateCharacterId,
},
profile.attributeSchema,
),
};
});
const storyNpcs = profile.storyNpcs.map((npc) => ({
...npc,
attributeProfile:
npc.attributeProfile ??
buildCustomWorldStoryNpcAttributeProfile(npc, profile.attributeSchema),
}));
return {
...profile,
playableNpcs,
storyNpcs,
scenarioPackId:
profile.scenarioPackId ?? `scenario-pack:${slugify(profile.name)}`,
campaignPackId:
profile.campaignPackId ?? `campaign-pack:${slugify(profile.name)}`,
};
}
function countUniqueNames(items: Array<{ name: string }>) {
return new Set(items.map((item) => item.name.trim()).filter(Boolean)).size;
}
export function validateGeneratedCustomWorldProfile(
profile: CustomWorldProfile,
) {
const playableCount = countUniqueNames(profile.playableNpcs);
const landmarkCount = countUniqueNames(profile.landmarks);
if (playableCount < MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT) {
throw new Error(
`自定义世界生成要求模型至少产出 ${MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT} 名可扮演角色。`,
);
}
if (landmarkCount < MIN_CUSTOM_WORLD_LANDMARK_COUNT) {
throw new Error(
`自定义世界生成要求至少产出 ${MIN_CUSTOM_WORLD_LANDMARK_COUNT} 个场景,当前仅返回 ${landmarkCount} 个。`,
);
}
const validStoryNpcIds = new Set(profile.storyNpcs.map((npc) => npc.id));
const validLandmarkIds = new Set(
profile.landmarks.map((landmark) => landmark.id),
);
profile.landmarks.forEach((landmark) => {
const uniqueSceneNpcIds = [...new Set(landmark.sceneNpcIds)];
if (uniqueSceneNpcIds.length < 3) {
throw new Error(
`场景「${landmark.name}」至少需要关联 3 个场景角色,当前仅有 ${uniqueSceneNpcIds.length} 个。`,
);
}
if (uniqueSceneNpcIds.some((npcId) => !validStoryNpcIds.has(npcId))) {
throw new Error(`场景「${landmark.name}」引用了不存在的场景角色。`);
}
if (landmark.connections.length === 0) {
throw new Error(`场景「${landmark.name}」缺少可用的场景连接关系。`);
}
if (
landmark.connections.some(
(connection) =>
connection.targetLandmarkId === landmark.id ||
!validLandmarkIds.has(connection.targetLandmarkId),
)
) {
throw new Error(`场景「${landmark.name}」存在无效的目标场景连接。`);
}
});
}

View File

@@ -0,0 +1,82 @@
import {
buildCustomWorldAnchorPackFromIntent,
deriveCustomWorldLockStateFromIntent,
normalizeCustomWorldCreatorIntent,
normalizeCustomWorldLockState,
} from '../creatorIntentRuntime.js';
import type {
CustomWorldCreatorIntent,
CustomWorldProfile,
WorldType,
} from '../runtimeTypes.js';
import { toText } from './normalizeShared.js';
/**
* 工作包 G
* 统一 runtime profile 对 creator intent、anchor pack 和 lock state 的桥接入口,
* 避免主编译器继续直接拼装这些兼容字段。
*/
export function inferWorldTypeFromSetting(settingText: string): WorldType {
return /[]/u.test(settingText)
? 'XIANXIA'
: 'WUXIA';
}
export function normalizeWorldType(value: unknown, sourceText: string): WorldType {
const worldType = toText(value).toUpperCase();
if (worldType === 'WUXIA' || worldType === 'XIANXIA') {
return worldType;
}
return inferWorldTypeFromSetting(sourceText);
}
export function buildSeedPhrase(settingText: string, fallback: string) {
const compact = settingText.replace(/\s+/g, '').trim();
return compact ? compact.slice(0, 10) : fallback;
}
export function buildWorldName(settingText: string, worldType: WorldType) {
const seed = buildSeedPhrase(settingText, '新旅');
const suffix = worldType === 'XIANXIA' ? '境' : '域';
return `${seed}${suffix}`;
}
export {
normalizeCustomWorldCreatorIntent,
normalizeCustomWorldLockState,
};
export function buildEmptyCustomWorldRuntimeBridge() {
return {
creatorIntent: null,
anchorPack: null,
lockState: normalizeCustomWorldLockState(null),
} satisfies {
creatorIntent: CustomWorldCreatorIntent | null;
anchorPack: CustomWorldProfile['anchorPack'];
lockState: CustomWorldProfile['lockState'];
};
}
export function resolveCustomWorldRuntimeIntentBridge(
raw: Record<string, unknown>,
) {
const creatorIntent = normalizeCustomWorldCreatorIntent(raw.creatorIntent);
return {
creatorIntent,
anchorPack:
raw.anchorPack && typeof raw.anchorPack === 'object'
? (raw.anchorPack as CustomWorldProfile['anchorPack'])
: buildCustomWorldAnchorPackFromIntent(creatorIntent),
lockState:
raw.lockState && typeof raw.lockState === 'object'
? normalizeCustomWorldLockState(raw.lockState)
: deriveCustomWorldLockStateFromIntent(creatorIntent),
} satisfies {
creatorIntent: CustomWorldCreatorIntent | null;
anchorPack: CustomWorldProfile['anchorPack'];
lockState: CustomWorldProfile['lockState'];
};
}

View File

@@ -0,0 +1,13 @@
/**
* 工作包 G
* custom world runtime profile 的主入口统一收口到目录化模块。
* 主编译逻辑和各类 normalize/build 子模块已经物理拆分,旧 compiler 文件只保留兼容转发。
*/
export * from './buildAttributeSchema.js';
export * from './buildCompiledProfile.js';
export * from './creatorIntentBridge.js';
export * from './normalizeCamp.js';
export * from './normalizeLandmark.js';
export * from './normalizeRole.js';
export * from './normalizeSceneChapter.js';
export * from './normalizeShared.js';

View File

@@ -0,0 +1,178 @@
import type {
CustomWorldCampScene,
CustomWorldGenerationCampOutline,
} from '../runtimeTypes.js';
import {
clampText,
toRecordArray,
toStringArray,
toText,
} from './normalizeShared.js';
/**
* 工作包 G
* 营地 fallback、outline 归一和 runtime 场景归一单独收口,
* 避免主编译器继续混合 UI 展示语义和营地领域默认值。
*/
export type CustomWorldCampFallbackProfile = {
name: string;
summary: string;
tone: string;
playerGoal: string;
settingText: string;
};
function detectCustomWorldThemeMode(profile: {
settingText: string;
summary: string;
tone: string;
playerGoal: string;
}) {
const source = [
profile.settingText,
profile.summary,
profile.tone,
profile.playerGoal,
].join(' ');
if (/[齿]/u.test(source)) return 'machina';
if (/[]/u.test(source)) return 'tide';
if (/[线]/u.test(source)) return 'rift';
if (/[]/u.test(source)) return 'arcane';
if (/[]/u.test(source)) return 'martial';
return 'mythic';
}
function sanitizeCampSeed(name: string) {
const normalized = name.trim().replace(/\s+/g, '');
if (!normalized) {
return '';
}
const stripped = normalized.replace(
/(|||||||||)$/u,
'',
);
const seed = stripped || normalized;
return seed.slice(0, Math.min(seed.length, 4));
}
function buildFallbackCampName(profile: CustomWorldCampFallbackProfile) {
const seed = sanitizeCampSeed(profile.name) || '归途';
const themeMode = detectCustomWorldThemeMode(profile);
const suffixByMode = {
mythic: '归舍',
martial: '归舍',
arcane: '栖居',
machina: '整备居',
tide: '潮居',
rift: '界隙居所',
} as const;
return `${seed}${suffixByMode[themeMode]}`;
}
export function buildFallbackCustomWorldCampScene(
profile: CustomWorldCampFallbackProfile,
): CustomWorldCampScene {
const fallbackName = buildFallbackCampName(profile);
const summaryLead = clampText(profile.summary, 24) || '前路尚未明朗';
const goalLead = clampText(profile.playerGoal, 24) || '继续整理线索';
const themeMode = detectCustomWorldThemeMode(profile);
const descriptionByMode = {
mythic: `${fallbackName}是你在${profile.name}暂时安顿的归处,能整备行装、交换判断,并围绕“${goalLead}”继续启程。`,
martial: `${fallbackName}是你在${profile.name}暂时落脚的归处,能整备行装、收拢同伴,并朝“${goalLead}”继续动身。`,
arcane: `${fallbackName}是你在${profile.name}暂时栖身的居所,适合调息、整理法器,并围绕“${summaryLead}”交换判断。`,
machina: `${fallbackName}是你在${profile.name}的临时居所,能检修装备、补齐物资,并为下一段行动重新校准节奏。`,
tide: `${fallbackName}是你在${profile.name}靠潮歇息的住处,能安顿队伍、整理补给,并顺着“${goalLead}”继续前探。`,
rift: `${fallbackName}是你在${profile.name}勉强守住的一处居所,既能短暂停步,也能围绕“${summaryLead}”商量下一步去向。`,
} as const;
return {
id: 'custom-scene-camp',
name: fallbackName,
description: descriptionByMode[themeMode],
dangerLevel: 'low',
sceneNpcIds: [],
connections: [],
narrativeResidues: null,
};
}
export function normalizeCampOutline(
value: unknown,
fallbackProfile: CustomWorldCampFallbackProfile,
) {
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
const item =
value && typeof value === 'object'
? (value as Record<string, unknown>)
: {};
return {
id: toText(item.id) || fallback.id,
name: toText(item.name) || fallback.name,
description: toText(item.description) || fallback.description,
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
imageSrc: toText(item.imageSrc) || undefined,
sceneNpcIds: toStringArray(item.sceneNpcIds),
connections: toRecordArray(item.connections)
.map((connection) => ({
targetLandmarkName:
toText(connection.targetLandmarkName) ||
toText(connection.target) ||
toText(connection.sceneName),
relativePosition:
toText(connection.relativePosition) ||
toText(connection.position) ||
'forward',
summary: toText(connection.summary) || toText(connection.description),
}))
.filter((connection) => connection.targetLandmarkName),
} satisfies CustomWorldGenerationCampOutline & {
id: string;
visualDescription?: string;
imageSrc?: string;
sceneNpcIds: string[];
connections: Array<{
targetLandmarkName: string;
relativePosition: string;
summary: string;
}>;
};
}
export function normalizeCampScene(
value: unknown,
fallbackProfile: CustomWorldCampFallbackProfile,
): CustomWorldCampScene {
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
const item =
value && typeof value === 'object'
? (value as Record<string, unknown>)
: {};
return {
id: toText(item.id) || fallback.id,
name: toText(item.name) || fallback.name,
description: toText(item.description) || fallback.description,
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
imageSrc: toText(item.imageSrc) || undefined,
sceneNpcIds: toStringArray(item.sceneNpcIds),
connections: toRecordArray(item.connections)
.map((connection) => ({
targetLandmarkId: toText(connection.targetLandmarkId),
relativePosition:
toText(connection.relativePosition) || toText(connection.position) || 'forward',
summary: toText(connection.summary) || toText(connection.description),
}))
.filter((connection) => connection.targetLandmarkId),
narrativeResidues: null,
};
}

View File

@@ -0,0 +1,151 @@
import type {
CustomWorldGenerationFramework,
CustomWorldGenerationLandmarkOutline,
CustomWorldNpc,
} from '../runtimeTypes.js';
import {
clampText,
createEntryId,
MIN_CUSTOM_WORLD_LANDMARK_COUNT,
toRecordArray,
toStringArray,
toText,
} from './normalizeShared.js';
/**
* 工作包 G
* 世界地点 outline/runtime 归一独立收口,避免地点网络解析继续和角色、场景章节逻辑混在一个文件里。
*/
export function normalizeLandmarkOutlineList(value: unknown) {
return toRecordArray(value)
.map((item) => {
const name = toText(item.name);
return {
name,
description:
toText(item.description) ||
clampText(`${name}暗藏新的局势变化。`, 40),
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel) || 'medium',
sceneNpcNames: [
...toStringArray(item.sceneNpcNames),
...toStringArray(item.npcs, 'name'),
...toStringArray(item.sceneNpcs, 'name'),
...toStringArray(item.npcNames),
],
connections: toRecordArray(item.connections)
.map((connection) => ({
targetLandmarkName:
toText(connection.targetLandmarkName) ||
toText(connection.target) ||
toText(connection.sceneName),
relativePosition:
toText(connection.relativePosition) ||
toText(connection.position) ||
'forward',
summary:
toText(connection.summary) || toText(connection.description),
}))
.filter((connection) => connection.targetLandmarkName),
} satisfies CustomWorldGenerationLandmarkOutline;
})
.filter((entry) => entry.name)
.slice(0, MIN_CUSTOM_WORLD_LANDMARK_COUNT);
}
export function normalizeCustomWorldGenerationLandmarkOutlineBatch(raw: unknown) {
const item =
raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
return normalizeLandmarkOutlineList(item.landmarks);
}
export function buildCustomWorldRawProfileLandmarksFromFramework(
framework: CustomWorldGenerationFramework,
) {
return framework.landmarks.map((landmark) => ({
name: landmark.name,
description: landmark.description,
visualDescription: landmark.visualDescription,
dangerLevel: landmark.dangerLevel,
sceneNpcNames: [...landmark.sceneNpcNames],
connections: landmark.connections.map((connection) => ({
targetLandmarkName: connection.targetLandmarkName,
relativePosition: connection.relativePosition,
summary: connection.summary,
})),
}));
}
export function normalizeLandmarks(params: {
landmarks: Array<Record<string, unknown>>;
storyNpcs: CustomWorldNpc[];
}) {
const storyNpcIdByName = new Map(
params.storyNpcs.map((npc) => [npc.name.trim(), npc.id] as const),
);
const landmarkEntries = params.landmarks
.map((item, index) => ({
id: toText(item.id) || createEntryId('landmark', toText(item.name), index),
name: toText(item.name),
description: toText(item.description),
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel) || 'medium',
imageSrc: toText(item.imageSrc) || undefined,
sceneNpcIds: toStringArray(item.sceneNpcIds),
sceneNpcNames: [
...toStringArray(item.sceneNpcNames),
...toStringArray(item.npcs, 'name'),
...toStringArray(item.sceneNpcs, 'name'),
...toStringArray(item.npcNames),
],
connections: toRecordArray(item.connections).map((connection) => ({
targetLandmarkId: toText(connection.targetLandmarkId),
targetLandmarkName:
toText(connection.targetLandmarkName) ||
toText(connection.target) ||
toText(connection.sceneName),
relativePosition:
toText(connection.relativePosition) || toText(connection.position),
summary: toText(connection.summary) || toText(connection.description),
})),
}))
.filter((entry) => entry.name);
const landmarkIdByName = new Map(
landmarkEntries.map((landmark) => [landmark.name.trim(), landmark.id] as const),
);
return landmarkEntries.map((landmark) => {
const resolvedSceneNpcIds = [
...new Set(
[
...landmark.sceneNpcIds,
...landmark.sceneNpcNames
.map((name) => storyNpcIdByName.get(name.trim()) ?? '')
.filter(Boolean),
].filter(Boolean),
),
];
return {
id: landmark.id,
name: landmark.name,
description: landmark.description,
visualDescription: landmark.visualDescription,
dangerLevel: landmark.dangerLevel,
imageSrc: landmark.imageSrc,
sceneNpcIds: resolvedSceneNpcIds,
connections: landmark.connections
.map((connection) => ({
targetLandmarkId:
connection.targetLandmarkId ||
landmarkIdByName.get(connection.targetLandmarkName.trim()) ||
'',
relativePosition: connection.relativePosition || 'forward',
summary: connection.summary,
}))
.filter((connection) => connection.targetLandmarkId),
};
});
}

View File

@@ -0,0 +1,541 @@
import type {
CharacterBackstoryChapter,
CharacterBackstoryRevealConfig,
CustomWorldGenerationFramework,
CustomWorldGenerationRoleBatchType,
CustomWorldGenerationRoleOutline,
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
CustomWorldRoleInitialItem,
CustomWorldRoleProfile,
CustomWorldRoleSkill,
} from '../runtimeTypes.js';
import {
buildWorldName,
normalizeWorldType,
} from './creatorIntentBridge.js';
import {
clampCustomWorldAffinity,
clampText,
createEntryId,
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
normalizeInitialAffinity,
normalizeRarity,
normalizeRoleItemCategory,
normalizeTags,
toRecordArray,
toText,
} from './normalizeShared.js';
/**
* 工作包 G
* 把角色相关的 outline/runtime 归一、背景揭示、初始技能与物品 fallback 统一收口,
* 让主编译器只负责装配,不继续内嵌角色画像细节。
*/
const AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS = [15, 30, 60, 90] as const;
const DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY = 60;
const DEFAULT_PLAYABLE_INITIAL_AFFINITY = 18;
const DEFAULT_STORY_NPC_INITIAL_AFFINITY = 6;
const DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT = 3;
const DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT = 3;
const CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = [
'表层来意',
'旧事裂痕',
'隐藏执念',
'最终底牌',
] as const;
type CustomWorldRoleFallbackSource = Pick<
CustomWorldRoleProfile,
| 'name'
| 'title'
| 'role'
| 'description'
| 'backstory'
| 'personality'
| 'motivation'
| 'combatStyle'
| 'relationshipHooks'
| 'tags'
>;
function splitNarrativeSentences(text: string) {
const normalized = text.replace(/\s+/g, ' ').trim();
if (!normalized) {
return [];
}
const matches = normalized.match(/[^!?]+[!?]?/gu);
return (matches ?? [normalized]).map((item) => item.trim()).filter(Boolean);
}
function buildFallbackBackstoryReveal(
source: CustomWorldRoleFallbackSource,
): CharacterBackstoryRevealConfig {
const normalizedBackstory =
source.backstory.trim() || `${source.name}对自己的过去始终有所保留。`;
const backstorySentences = splitNarrativeSentences(normalizedBackstory);
const backstoryLead = backstorySentences[0] ?? normalizedBackstory;
const backstoryDetail =
backstorySentences.slice(0, 2).join('') || normalizedBackstory;
const publicSummary =
source.description.trim() || clampText(normalizedBackstory, 42);
const fallbackContents = [
source.description.trim() || backstoryLead,
backstoryDetail,
source.motivation.trim()
? `${source.name}真正挂念的,是:${source.motivation.trim()}`
: `${source.name}的选择与“${clampText(backstoryLead, 24)}”直接相关。`,
source.personality.trim()
? `${source.name}不会轻易说出的底色,是:${source.personality.trim()}`
: `${source.name}仍把最深的筹码藏在过去之中。`,
];
return {
publicSummary,
privateChatUnlockAffinity: DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
chapters: AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.map(
(affinityRequired, index) =>
({
id: createEntryId(
'backstory-chapter',
`${source.name}-${CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? index + 1}`,
index,
),
title:
CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ??
`背景片段${index + 1}`,
affinityRequired,
teaser: clampText(
fallbackContents[index] ?? normalizedBackstory,
22,
),
content: clampText(
fallbackContents[index] ?? normalizedBackstory,
72,
),
contextSnippet: clampText(
`${source.name}的背景正在向“${fallbackContents[index] ?? normalizedBackstory}”这条线索展开。`,
48,
),
}) satisfies CharacterBackstoryChapter,
),
};
}
function normalizeBackstoryReveal(
value: unknown,
fallbackSource: CustomWorldRoleFallbackSource,
) {
const fallback = buildFallbackBackstoryReveal(fallbackSource);
if (!value || typeof value !== 'object') {
return fallback;
}
const item = value as Record<string, unknown>;
const rawChapters = toRecordArray(item.chapters);
return {
publicSummary: toText(item.publicSummary) || fallback.publicSummary,
privateChatUnlockAffinity:
typeof item.privateChatUnlockAffinity === 'number' &&
Number.isFinite(item.privateChatUnlockAffinity)
? clampCustomWorldAffinity(item.privateChatUnlockAffinity)
: fallback.privateChatUnlockAffinity,
chapters: AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.map(
(defaultAffinity, index) => {
const fallbackChapter = fallback.chapters[index];
const rawChapter = rawChapters[index];
return {
id:
(rawChapter && toText(rawChapter.id)) ||
fallbackChapter?.id ||
`backstory-chapter-${index + 1}`,
title:
(rawChapter && toText(rawChapter.title)) ||
fallbackChapter?.title ||
`背景片段${index + 1}`,
affinityRequired:
fallbackChapter?.affinityRequired ?? defaultAffinity,
teaser:
(rawChapter && toText(rawChapter.teaser)) ||
fallbackChapter?.teaser ||
'',
content:
(rawChapter && toText(rawChapter.content)) ||
fallbackChapter?.content ||
'',
contextSnippet:
(rawChapter && toText(rawChapter.contextSnippet)) ||
fallbackChapter?.contextSnippet ||
'',
} satisfies CharacterBackstoryChapter;
},
),
} satisfies CharacterBackstoryRevealConfig;
}
function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) {
const skillNameSeed = source.title || source.role || source.name || '角色';
const skillSummarySeed =
source.combatStyle || source.description || `${source.name}善于把握局势。`;
const motivationSeed =
source.motivation || source.personality || source.backstory;
return [
{
id: createEntryId('role-skill', `${skillNameSeed}-起手`, 0),
name: `${skillNameSeed}起手`,
summary: clampText(skillSummarySeed, 36),
style: '起手压制',
},
{
id: createEntryId('role-skill', `${skillNameSeed}-机动`, 1),
name: `${skillNameSeed}变招`,
summary: clampText(
source.personality || `${source.name}习惯在试探中寻找破绽。`,
36,
),
style: '机动周旋',
},
{
id: createEntryId('role-skill', `${skillNameSeed}-底牌`, 2),
name: `${skillNameSeed}底牌`,
summary: clampText(
motivationSeed || `${source.name}会在关键时刻亮出压箱手段。`,
36,
),
style: '爆发终结',
},
] satisfies CustomWorldRoleSkill[];
}
function normalizeRoleSkillList(
value: unknown,
fallbackSource: CustomWorldRoleFallbackSource,
) {
const normalized = toRecordArray(value)
.map((item, index) => {
const name = toText(item.name);
const summary = toText(item.summary) || toText(item.description);
const style = toText(item.style) || toText(item.category) || '常用';
return {
id: createEntryId('role-skill', name || style, index),
name,
summary,
style,
} satisfies CustomWorldRoleSkill;
})
.filter((entry) => entry.name)
.slice(0, DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT);
return normalized.length > 0
? normalized
: buildFallbackRoleSkills(fallbackSource);
}
function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) {
const itemNameSeed = source.title || source.role || source.name || '角色';
return [
{
id: createEntryId('role-item', `${itemNameSeed}-1`, 0),
name: `${itemNameSeed}常备武具`,
category: '武器',
quantity: 1,
rarity: 'rare',
description: clampText(
source.combatStyle || `${source.name}随身携带的主要作战物件。`,
36,
),
tags: normalizeTags(source.tags, ['战斗', '随身']),
},
{
id: createEntryId('role-item', `${itemNameSeed}-2`, 1),
name: `${itemNameSeed}补给包`,
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: clampText(
source.personality || `${source.name}为了长期行动准备的基础补给。`,
36,
),
tags: normalizeTags(source.relationshipHooks, ['补给', '行动']),
},
{
id: createEntryId('role-item', `${itemNameSeed}-3`, 2),
name: `${itemNameSeed}私人物件`,
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: clampText(
source.backstory ||
source.motivation ||
`${source.name}不愿随意交出的信物。`,
36,
),
tags: normalizeTags(
[...source.tags, ...source.relationshipHooks],
['信物', '线索'],
),
},
] satisfies CustomWorldRoleInitialItem[];
}
function normalizeRoleInitialItemList(
value: unknown,
fallbackSource: CustomWorldRoleFallbackSource,
) {
const normalized = toRecordArray(value)
.map((item, index) => {
const name = toText(item.name);
return {
id: createEntryId('role-item', name, index),
name,
category: normalizeRoleItemCategory(item.category),
quantity:
typeof item.quantity === 'number' && Number.isFinite(item.quantity)
? Math.max(1, Math.min(99, Math.round(item.quantity)))
: 1,
rarity: normalizeRarity(item.rarity, 'rare'),
description: toText(item.description),
tags: normalizeTags(item.tags),
} satisfies CustomWorldRoleInitialItem;
})
.filter((entry) => entry.name)
.slice(0, DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT);
return normalized.length > 0
? normalized
: buildFallbackRoleInitialItems(fallbackSource);
}
function normalizeRoleOutlineList(
value: unknown,
options: {
titleFallback: string;
defaultAffinity: number;
maxCount?: number;
},
) {
const normalized = toRecordArray(value)
.map((item) => {
const name = toText(item.name);
const title =
toText(item.title) || toText(item.role) || options.titleFallback;
const role = toText(item.role) || title;
const relationshipHooks = normalizeTags(
item.relationshipHooks,
normalizeTags(item.tags),
);
return {
name,
title,
role,
description:
toText(item.description) ||
clampText(`${name || title}在世界中以${role}身份活动。`, 36),
visualDescription: toText(item.visualDescription) || undefined,
actionDescription: toText(item.actionDescription) || undefined,
sceneVisualDescription: toText(item.sceneVisualDescription) || undefined,
initialAffinity: normalizeInitialAffinity(
item.initialAffinity,
options.defaultAffinity,
),
relationshipHooks,
tags: normalizeTags(item.tags, relationshipHooks),
} satisfies CustomWorldGenerationRoleOutline;
})
.filter((entry) => entry.name);
return typeof options.maxCount === 'number'
? normalized.slice(0, options.maxCount)
: normalized;
}
export function normalizeCustomWorldGenerationRoleOutlineBatch(
raw: unknown,
roleType: CustomWorldGenerationRoleBatchType,
) {
const item =
raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs';
return normalizeRoleOutlineList(item[key], {
titleFallback: '未定称号',
defaultAffinity:
roleType === 'playable'
? DEFAULT_PLAYABLE_INITIAL_AFFINITY
: DEFAULT_STORY_NPC_INITIAL_AFFINITY,
});
}
export function normalizeCustomWorldGenerationFrameworkRoles(params: {
raw: Record<string, unknown>;
fallback: CustomWorldProfile;
settingText: string;
}) {
const worldSignalText = [
params.settingText,
toText(params.raw.subtitle),
toText(params.raw.summary),
toText(params.raw.tone),
toText(params.raw.playerGoal),
].join(' ');
const templateWorldType = normalizeWorldType(
params.raw.templateWorldType,
worldSignalText,
);
const name =
toText(params.raw.name) || buildWorldName(params.settingText, templateWorldType);
return {
name,
templateWorldType,
playableNpcs: normalizeRoleOutlineList(params.raw.playableNpcs, {
titleFallback: '未定称号',
defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY,
maxCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
}),
storyNpcs: normalizeRoleOutlineList(params.raw.storyNpcs, {
titleFallback: '未定称号',
defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY,
maxCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
}),
campFallbackProfile: {
name,
summary: toText(params.raw.summary) || params.fallback.summary,
tone: toText(params.raw.tone) || params.fallback.tone,
playerGoal: toText(params.raw.playerGoal) || params.fallback.playerGoal,
settingText: params.settingText.trim(),
},
};
}
export function buildCustomWorldRawProfileRolesFromFramework(
framework: CustomWorldGenerationFramework,
) {
return {
playableNpcs: framework.playableNpcs.map((npc) => ({
name: npc.name,
title: npc.title,
role: npc.role,
description: npc.description,
visualDescription: npc.visualDescription,
actionDescription: npc.actionDescription,
sceneVisualDescription: npc.sceneVisualDescription,
initialAffinity: npc.initialAffinity,
relationshipHooks: [...npc.relationshipHooks],
tags: [...npc.tags],
})),
storyNpcs: framework.storyNpcs.map((npc) => ({
name: npc.name,
title: npc.title,
role: npc.role,
description: npc.description,
visualDescription: npc.visualDescription,
actionDescription: npc.actionDescription,
sceneVisualDescription: npc.sceneVisualDescription,
initialAffinity: npc.initialAffinity,
relationshipHooks: [...npc.relationshipHooks],
tags: [...npc.tags],
})),
};
}
function normalizeRoleProfile(
item: Record<string, unknown>,
index: number,
options: {
idPrefix: 'playable-npc' | 'story-npc';
titleFallback: string;
defaultAffinity: number;
},
) {
const name = toText(item.name);
const title =
toText(item.title) || toText(item.role) || options.titleFallback;
const role = toText(item.role) || title;
const relationshipHooks = normalizeTags(
item.relationshipHooks,
normalizeTags(item.tags),
);
const normalizedRole = {
id: toText(item.id) || createEntryId(options.idPrefix, name, index),
name,
title,
role,
description: toText(item.description),
visualDescription: toText(item.visualDescription) || undefined,
actionDescription: toText(item.actionDescription) || undefined,
sceneVisualDescription: toText(item.sceneVisualDescription) || undefined,
backstory: toText(item.backstory),
personality: toText(item.personality),
motivation: toText(item.motivation) || toText(item.description),
combatStyle: toText(item.combatStyle),
initialAffinity: normalizeInitialAffinity(
item.initialAffinity,
options.defaultAffinity,
),
relationshipHooks,
tags: normalizeTags(item.tags, relationshipHooks),
};
return {
...normalizedRole,
backstoryReveal: normalizeBackstoryReveal(
item.backstoryReveal,
normalizedRole,
),
skills: normalizeRoleSkillList(item.skills, normalizedRole),
initialItems: normalizeRoleInitialItemList(item.initialItems, normalizedRole),
imageSrc: toText(item.imageSrc) || undefined,
generatedVisualAssetId: toText(item.generatedVisualAssetId) || undefined,
generatedAnimationSetId:
toText(item.generatedAnimationSetId) || undefined,
animationMap:
item.animationMap && typeof item.animationMap === 'object'
? (item.animationMap as Record<string, unknown>)
: undefined,
narrativeProfile:
item.narrativeProfile && typeof item.narrativeProfile === 'object'
? (item.narrativeProfile as CustomWorldRoleProfile['narrativeProfile'])
: null,
};
}
export function normalizePlayableNpcList(value: unknown) {
return toRecordArray(value)
.map((item, index) => ({
...normalizeRoleProfile(item, index, {
idPrefix: 'playable-npc',
titleFallback: '未定称号',
defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY,
}),
templateCharacterId: toText(item.templateCharacterId) || undefined,
}))
.filter((entry) => entry.name)
.slice(0, MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT);
}
export function normalizeStoryNpcList(value: unknown) {
return toRecordArray(value)
.map(
(item, index) =>
({
...normalizeRoleProfile(item, index, {
idPrefix: 'story-npc',
titleFallback: '未定称号',
defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY,
}),
visual:
item.visual && typeof item.visual === 'object'
? (item.visual as Record<string, unknown>)
: undefined,
}) satisfies CustomWorldNpc,
)
.filter((entry) => entry.name);
}

View File

@@ -0,0 +1,123 @@
import type { SceneActBlueprint, SceneChapterBlueprint } from '../runtimeTypes.js';
import { createEntryId, toRecordArray, toStringArray, toText } from './normalizeShared.js';
/**
* 工作包 G
* 分幕与场景章节 blueprint 归一独立收口,让结果预览编译层只消费稳定的章节结构。
*/
const SCENE_ACT_STAGES = new Set([
'opening',
'expansion',
'turning_point',
'climax',
'aftermath',
]);
const SCENE_ACT_ADVANCE_RULES = new Set([
'after_primary_contact',
'after_active_step_complete',
'after_chapter_resolution',
]);
function normalizeSceneActStageCoverage(value: unknown) {
const stageCoverage = Array.isArray(value)
? value
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry): entry is SceneActBlueprint['stageCoverage'][number] =>
SCENE_ACT_STAGES.has(entry as never),
)
: [];
return [...new Set(stageCoverage)];
}
function normalizeSceneActBlueprint(
value: unknown,
index: number,
sceneId: string,
): SceneActBlueprint | null {
const item =
value && typeof value === 'object'
? (value as Record<string, unknown>)
: null;
if (!item) {
return null;
}
const encounterNpcIds = toStringArray(item.encounterNpcIds);
const stageCoverage = normalizeSceneActStageCoverage(item.stageCoverage);
const advanceRule = toText(item.advanceRule);
const title = toText(item.title);
const summary = toText(item.summary);
if (!title && !summary && encounterNpcIds.length === 0) {
return null;
}
return {
id:
toText(item.id) ||
createEntryId(`saved-scene-act-${sceneId}`, title || sceneId, index),
sceneId,
title: title || `${index + 1}`,
summary: summary || title || `围绕${sceneId}继续推进`,
stageCoverage:
stageCoverage.length > 0
? stageCoverage
: index === 0
? ['opening']
: ['climax', 'aftermath'],
backgroundImageSrc: toText(item.backgroundImageSrc) || undefined,
backgroundAssetId: toText(item.backgroundAssetId) || undefined,
encounterNpcIds,
primaryNpcId: toText(item.primaryNpcId) || encounterNpcIds[0] || '',
linkedThreadIds: toStringArray(item.linkedThreadIds),
advanceRule: SCENE_ACT_ADVANCE_RULES.has(advanceRule as never)
? (advanceRule as SceneActBlueprint['advanceRule'])
: 'after_active_step_complete',
actGoal: toText(item.actGoal),
transitionHook: toText(item.transitionHook),
};
}
export function normalizeSceneChapterBlueprints(value: unknown) {
if (!Array.isArray(value)) {
return null;
}
const normalized = value
.filter(
(entry): entry is Record<string, unknown> =>
Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry),
)
.map((entry, index) => {
const sceneId = toText(entry.sceneId);
if (!sceneId) {
return null;
}
const acts = Array.isArray(entry.acts)
? entry.acts
.map((act, actIndex) =>
normalizeSceneActBlueprint(act, actIndex, sceneId),
)
.filter((act): act is SceneActBlueprint => Boolean(act))
: [];
return {
id:
toText(entry.id) ||
createEntryId('saved-scene-chapter', sceneId, index),
sceneId,
title: toText(entry.title) || toText(entry.sceneName) || sceneId,
summary: toText(entry.summary),
linkedThreadIds: toStringArray(entry.linkedThreadIds),
linkedLandmarkIds: toStringArray(entry.linkedLandmarkIds),
acts,
} satisfies SceneChapterBlueprint;
})
.filter((entry): entry is SceneChapterBlueprint => Boolean(entry));
return normalized.length > 0 ? normalized : null;
}

View File

@@ -0,0 +1,248 @@
import type {
CustomWorldCoverProfile,
CustomWorldCoverSourceType,
CustomWorldItem,
CustomWorldPlayableNpc,
} from '../runtimeTypes.js';
/**
* 工作包 G
* 把 runtime profile 编译流程里的通用常量、基础文本归一和通用小型归一逻辑收口到共享模块,
* 让角色、地点、场景章节和主编译入口都不再重复维护这些基础能力。
*/
const MIN_CUSTOM_WORLD_AFFINITY = -40;
const MAX_CUSTOM_WORLD_AFFINITY = 90;
const CUSTOM_WORLD_RARITIES = [
'common',
'uncommon',
'rare',
'epic',
'legendary',
] as const;
const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = [
'武器',
'护甲',
'饰品',
'消耗品',
'材料',
'稀有品',
'专属物品',
'专属物',
] as const;
export const MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT = 5;
export const MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT = 30;
export const MIN_CUSTOM_WORLD_LANDMARK_COUNT = 10;
export const MIN_CUSTOM_WORLD_STORY_NPC_COUNT = Math.max(
0,
MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT - MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
);
export const PLAYABLE_TEMPLATE_CHARACTER_IDS = [
'sword-princess',
'archer-hero',
'girl-hero',
'punch-hero',
'fighter-4',
] as const;
export function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
export function toFiniteInteger(value: unknown) {
return typeof value === 'number' && Number.isFinite(value)
? Math.round(value)
: undefined;
}
export function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
export function toRecordArray(value: unknown) {
return Array.isArray(value)
? (value.filter((item) => item && typeof item === 'object') as Array<
Record<string, unknown>
>)
: [];
}
export function toStringArray(value: unknown, nestedKey?: string) {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => {
if (typeof item === 'string') {
return item.trim();
}
if (nestedKey && item && typeof item === 'object') {
return toText((item as Record<string, unknown>)[nestedKey]);
}
return '';
})
.filter(Boolean);
}
export function normalizeTags(value: unknown, fallbackTags: string[] = []) {
const tags = Array.isArray(value)
? value.map((item) => toText(item)).filter(Boolean)
: [];
return [
...new Set((tags.length > 0 ? tags : fallbackTags).filter(Boolean)),
].slice(0, 5);
}
export function clampText(value: string, maxLength: number) {
const normalized = value.trim().replace(/\s+/g, ' ');
if (!normalized) {
return '';
}
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}`;
}
export function slugify(value: string) {
const ascii = value
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
.replace(/^-+|-+$/g, '');
return ascii ? ascii.slice(0, 24) : 'entry';
}
export function createEntryId(prefix: string, label: string, index: number) {
return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`;
}
export function clampCustomWorldAffinity(value: number) {
return Math.max(
MIN_CUSTOM_WORLD_AFFINITY,
Math.min(MAX_CUSTOM_WORLD_AFFINITY, Math.round(value)),
);
}
export function normalizeInitialAffinity(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value)
? clampCustomWorldAffinity(value)
: fallback;
}
export function normalizeRarity(
value: unknown,
fallback: (typeof CUSTOM_WORLD_RARITIES)[number] = 'rare',
) {
const rarity = toText(value).toLowerCase();
return CUSTOM_WORLD_RARITIES.includes(
rarity as (typeof CUSTOM_WORLD_RARITIES)[number],
)
? (rarity as (typeof CUSTOM_WORLD_RARITIES)[number])
: fallback;
}
export function normalizeRoleItemCategory(value: unknown, fallback = '材料') {
const category = toText(value);
if (
(CUSTOM_WORLD_ROLE_ITEM_CATEGORIES as readonly string[]).includes(category)
) {
return category === '专属物' ? '专属物品' : category;
}
if (/||||||/u.test(category)) return '';
if (/||||/u.test(category)) return '';
if (/|||||/u.test(category)) return '';
if (/||||/u.test(category)) return '';
if (/|||||/u.test(category)) return '';
if (/|||||/u.test(category)) return '';
if (/||||/u.test(category)) return '';
return fallback;
}
export function normalizeCustomWorldCoverCharacterRoleIds(
value: unknown,
playableNpcs: Array<Pick<CustomWorldPlayableNpc, 'id'>>,
) {
const availableIds = new Set(
playableNpcs.map((entry) => entry.id.trim()).filter(Boolean),
);
const selectedIds = Array.isArray(value)
? [
...new Set(
value
.map((entry) => toText(entry))
.filter((entry) => entry && availableIds.has(entry)),
),
].slice(0, 3)
: [];
if (selectedIds.length > 0) {
return selectedIds;
}
return playableNpcs
.map((entry) => entry.id.trim())
.filter(Boolean)
.slice(0, 3);
}
export function buildDefaultCustomWorldCover(
playableNpcs: Array<Pick<CustomWorldPlayableNpc, 'id'>>,
): CustomWorldCoverProfile {
return {
sourceType: 'default' as const,
imageSrc: null,
characterRoleIds: normalizeCustomWorldCoverCharacterRoleIds(
undefined,
playableNpcs,
),
};
}
export function normalizeCustomWorldCover(
value: unknown,
playableNpcs: Array<Pick<CustomWorldPlayableNpc, 'id'>>,
): CustomWorldCoverProfile {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return buildDefaultCustomWorldCover(playableNpcs);
}
const item = value as Record<string, unknown>;
const sourceType: CustomWorldCoverSourceType =
item.sourceType === 'uploaded' || item.sourceType === 'generated'
? item.sourceType
: 'default';
const imageSrc = toText(item.imageSrc) || null;
if (sourceType !== 'default' && imageSrc) {
return {
sourceType,
imageSrc,
characterRoleIds: [],
};
}
return buildDefaultCustomWorldCover(playableNpcs);
}
export function normalizeItemList(value: unknown) {
return toRecordArray(value)
.map((item, index) => {
const name = toText(item.name);
const category = toText(item.category);
return {
id: toText(item.id) || createEntryId('item', name, index),
name,
category,
rarity: normalizeRarity(item.rarity, 'rare'),
description: toText(item.description),
tags: normalizeTags(item.tags),
} satisfies CustomWorldItem;
})
.filter((entry) => entry.name && entry.category);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
export * from './inventoryMutationService.js';

View File

@@ -1,7 +1,7 @@
import type {
RuntimeStoryActionRequest,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import { conflict, invalidRequest } from '../../errors.js';
import {
getPlayerBuildDamageBreakdown,
@@ -19,8 +19,8 @@ import {
} from './inventoryMutationService.js';
import {
replaceRuntimeSessionRawGameState,
type RuntimeSession,
} from '../story/runtimeSession.js';
} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js';
import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js';
const SUPPORTED_INVENTORY_STORY_FUNCTION_IDS = new Set<string>([
'equipment_equip',

View File

@@ -1,7 +1,7 @@
import type {
RuntimeStoryActionRequest,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import { conflict, invalidRequest } from '../../errors.js';
import {
addInventoryItems,
@@ -23,8 +23,8 @@ import {
} from '../../bridges/legacyNpcTask6Bridge.js';
import {
replaceRuntimeSessionRawGameState,
type RuntimeSession,
} from '../story/runtimeSession.js';
} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js';
import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js';
const SUPPORTED_NPC_INVENTORY_STORY_FUNCTION_IDS = new Set<string>([
'npc_gift',

View File

@@ -1,6 +1,10 @@
import type { RuntimeStoryPatch } from '../../../../packages/shared/src/contracts/story.js';
import type { RuntimeStoryPatch } from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import { conflict } from '../../errors.js';
import { resolveHostileBattleProfile } from '../progression/hostileProgressionService.js';
import {
applyStoryChoiceToStanceProfile,
} from './npcTask6Primitives.js';
import { markNpcFirstMeaningfulContactResolved } from '../runtime/runtimeNpcStatePrimitives.js';
import {
MAX_TASK5_COMPANIONS,
getEncounterNpcState,
@@ -8,7 +12,7 @@ import {
type RuntimeEncounter,
type RuntimeNpcState,
type RuntimeSession,
} from '../story/runtimeSession.js';
} from '../rpg-runtime-story/RpgRuntimeSessionPrimitives.js';
type JsonRecord = Record<string, unknown>;
@@ -57,6 +61,158 @@ function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function buildRecruitedCompanion(
session: RuntimeSession,
encounter: RuntimeEncounter,
npcState: RuntimeNpcState,
) {
const rawCompanionSource = isRecord(session.rawGameState.currentEncounter)
? session.rawGameState.currentEncounter
: {};
const maxHp = Math.max(
1,
Math.round(
typeof rawCompanionSource.maxHp === 'number' &&
Number.isFinite(rawCompanionSource.maxHp)
? rawCompanionSource.maxHp
: 180,
),
);
const maxMana = Math.max(
1,
Math.round(
typeof rawCompanionSource.maxMana === 'number' &&
Number.isFinite(rawCompanionSource.maxMana)
? rawCompanionSource.maxMana
: 999,
),
);
const skillCooldowns = Object.fromEntries(
Object.entries(
isRecord(rawCompanionSource.skillCooldowns)
? rawCompanionSource.skillCooldowns
: {},
).map(([skillId, turns]) => [
skillId,
typeof turns === 'number' && Number.isFinite(turns)
? Math.max(0, Math.round(turns))
: 0,
]),
);
return {
npcId: encounter.id,
characterId: encounter.characterId ?? '',
joinedAtAffinity: npcState.affinity,
hp: maxHp,
maxHp,
mana: maxMana,
maxMana,
skillCooldowns,
animationState: readString(rawCompanionSource.animationState) || 'idle',
actionMode: readString(rawCompanionSource.actionMode) || 'idle',
offsetX:
typeof rawCompanionSource.offsetX === 'number' &&
Number.isFinite(rawCompanionSource.offsetX)
? rawCompanionSource.offsetX
: 0,
offsetY:
typeof rawCompanionSource.offsetY === 'number' &&
Number.isFinite(rawCompanionSource.offsetY)
? rawCompanionSource.offsetY
: 0,
transitionMs:
typeof rawCompanionSource.transitionMs === 'number' &&
Number.isFinite(rawCompanionSource.transitionMs)
? Math.max(0, Math.round(rawCompanionSource.transitionMs))
: 0,
};
}
function upsertCompanion(
list: RuntimeSession['companions'],
companion: RuntimeSession['companions'][number],
) {
const next = [...list];
const existingIndex = next.findIndex((item) => item.npcId === companion.npcId);
if (existingIndex >= 0) {
next[existingIndex] = companion;
return next;
}
next.push(companion);
return next;
}
function removeCompanion(
list: RuntimeSession['companions'],
npcId: string,
) {
return list.filter((item) => item.npcId !== npcId);
}
function normalizeRoster(
roster: RuntimeSession['roster'],
activeCompanions: RuntimeSession['companions'],
) {
const activeIds = new Set(activeCompanions.map((companion) => companion.npcId));
return roster.filter((companion) => !activeIds.has(companion.npcId));
}
function recruitCompanionToParty(params: {
session: RuntimeSession;
companion: RuntimeSession['companions'][number];
releaseNpcId?: string | null;
}) {
const nextRosterWithoutRecruit = removeCompanion(
params.session.roster,
params.companion.npcId,
);
if (
!params.releaseNpcId &&
params.session.companions.length < MAX_TASK5_COMPANIONS
) {
return {
companions: [...params.session.companions, params.companion],
roster: nextRosterWithoutRecruit,
releasedCompanion: null,
};
}
if (!params.releaseNpcId) {
throw conflict('队伍已满时必须明确指定一名离队同伴');
}
const replaceIndex = params.session.companions.findIndex(
(item) => item.npcId === params.releaseNpcId,
);
if (replaceIndex < 0) {
throw conflict('指定的离队同伴不存在,无法完成换队招募');
}
const releasedCompanion = params.session.companions[replaceIndex];
if (!releasedCompanion) {
throw conflict('指定的离队同伴不存在,无法完成换队招募');
}
const nextCompanions = [...params.session.companions];
nextCompanions[replaceIndex] = params.companion;
return {
companions: nextCompanions,
roster: normalizeRoster(
upsertCompanion(nextRosterWithoutRecruit, releasedCompanion),
nextCompanions,
),
releasedCompanion,
};
}
function buildBattleTarget(
encounter: RuntimeEncounter,
rawGameState: JsonRecord,
@@ -92,6 +248,7 @@ function buildBattleTarget(
export function resolveNpcInteraction(
session: RuntimeSession,
functionId: string,
payload?: JsonRecord,
): NpcInteractionResolution {
const encounter = requireNpcEncounter(session);
const npcState = requireNpcState(session, encounter);
@@ -179,20 +336,29 @@ export function resolveNpcInteraction(
if (npcState.affinity < 60) {
throw conflict('当前关系还没达到招募阈值,暂时不能邀请入队');
}
if (session.companions.length >= MAX_TASK5_COMPANIONS) {
throw conflict('队伍已满任务5首轮后端接口暂不处理换队逻辑');
}
setEncounterNpcState(session, {
...npcState,
const releaseNpcId = readString(payload?.releaseNpcId) || null;
const recruitedCompanion = buildRecruitedCompanion(
session,
encounter,
npcState,
);
const recruitmentResult = recruitCompanionToParty({
session,
companion: recruitedCompanion,
releaseNpcId,
});
const nextNpcState = {
...markNpcFirstMeaningfulContactResolved(npcState),
recruited: true,
firstMeaningfulContactResolved: true,
});
session.companions.push({
npcId: encounter.id,
characterId: encounter.characterId ?? '',
joinedAtAffinity: npcState.affinity,
});
stanceProfile: applyStoryChoiceToStanceProfile(
npcState.stanceProfile,
'npc_recruit',
{ recruited: true },
),
};
setEncounterNpcState(session, nextNpcState);
session.companions = recruitmentResult.companions;
session.roster = recruitmentResult.roster;
session.currentEncounter = null;
session.npcInteractionActive = false;
session.currentNpcBattleMode = null;
@@ -202,7 +368,9 @@ export function resolveNpcInteraction(
return {
actionText: `邀请${encounter.npcName}加入队伍`,
resultText: `${encounter.npcName}接受了你的邀请,正式进入了同行队伍。`,
resultText: recruitmentResult.releasedCompanion
? `${encounter.npcName}接受了你的邀请,你先让一名当前同行暂时离队,把位置腾给了新的同行者。`
: `${encounter.npcName}接受了你的邀请,正式进入了同行队伍。`,
patches: [
{
type: 'status_changed',

View File

@@ -1,2 +0,0 @@
export * from './questProgressionService.js';
export { generateQuestForNpcEncounter } from '../../services/questService.js';

View File

@@ -1,12 +1,12 @@
import type { RuntimeBattlePresentation } from '../../../../packages/shared/src/contracts/story.js';
import type { RuntimeBattlePresentation } from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import {
applyQuestSignal,
normalizeQuestEntries,
} from './questProgressionService.js';
import {
replaceRuntimeSessionRawGameState,
type RuntimeSession,
} from '../story/runtimeSession.js';
} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js';
import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js';
type JsonRecord = Record<string, unknown>;
type RuntimeGameState = {

View File

@@ -1,7 +1,8 @@
import type {
RuntimeStoryOptionView,
RuntimeStoryActionRequest,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import {
buildExperienceGrantResultText,
grantPlayerExperience,
@@ -25,10 +26,13 @@ import {
} from './questTask6Bridge.js';
import {
replaceRuntimeSessionRawGameState,
type RuntimeSession,
} from '../story/runtimeSession.js';
} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js';
import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js';
const SUPPORTED_QUEST_STORY_FUNCTION_IDS = new Set<string>([
'npc_chat_quest_offer_abandon',
'npc_chat_quest_offer_replace',
'npc_chat_quest_offer_view',
'npc_quest_accept',
'npc_quest_turn_in',
]);
@@ -37,6 +41,9 @@ type QuestStoryResolution = {
actionText: string;
resultText: string;
patches: RuntimeStoryPatch[];
storyText?: string;
presentationOptions?: RuntimeStoryOptionView[];
savedCurrentStory?: JsonRecord;
};
type JsonRecord = Record<string, unknown>;
@@ -140,6 +147,144 @@ function readPendingQuestOffer(
return quest as RuntimeQuestLogEntry;
}
function readPendingQuestOfferContext(
currentStory: unknown,
npcKey: string,
) {
if (!isObject(currentStory)) {
return null;
}
const npcChatState = isObject(currentStory.npcChatState)
? currentStory.npcChatState
: null;
const pendingQuestOffer = isObject(npcChatState?.pendingQuestOffer)
? npcChatState.pendingQuestOffer
: null;
const quest = readPendingQuestOffer(currentStory, npcKey);
if (!quest) {
return null;
}
const dialogue = Array.isArray(currentStory.dialogue)
? currentStory.dialogue
.filter((entry) => isObject(entry))
.map((entry) => ({ ...entry }))
: [];
const turnCount =
typeof npcChatState?.turnCount === 'number' &&
Number.isFinite(npcChatState.turnCount)
? Math.max(0, Math.round(npcChatState.turnCount))
: 0;
const customInputPlaceholder =
readString(npcChatState?.customInputPlaceholder) || '输入你想对 TA 说的话';
return {
dialogue,
turnCount,
customInputPlaceholder,
quest,
introText: readString(pendingQuestOffer?.introText),
};
}
function buildNpcChatOption(
encounter: RuntimeEncounter,
actionText: string,
) {
return {
functionId: 'npc_chat',
actionText,
text: actionText,
detailText: '',
interaction: {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'chat',
},
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
} satisfies JsonRecord;
}
function buildPendingQuestOfferOptions(encounter: RuntimeEncounter) {
const npcId = encounter.id ?? encounter.npcName;
const buildOption = (
functionId:
| 'npc_chat_quest_offer_view'
| 'npc_chat_quest_offer_replace'
| 'npc_chat_quest_offer_abandon',
actionText: string,
action: 'quest_offer_view' | 'quest_offer_replace' | 'quest_offer_abandon',
) =>
({
functionId,
actionText,
text: actionText,
detailText: '',
interaction: {
kind: 'npc',
npcId,
action,
},
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
runtimePayload:
functionId === 'npc_chat_quest_offer_view'
? { npcChatQuestOfferAction: 'view' }
: functionId === 'npc_chat_quest_offer_replace'
? { npcChatQuestOfferAction: 'replace' }
: { npcChatQuestOfferAction: 'abandon' },
}) satisfies JsonRecord;
return [
buildOption('npc_chat_quest_offer_view', '查看任务', 'quest_offer_view'),
buildOption(
'npc_chat_quest_offer_replace',
'更换任务',
'quest_offer_replace',
),
buildOption(
'npc_chat_quest_offer_abandon',
'放弃任务',
'quest_offer_abandon',
),
];
}
function buildPostQuestOfferChatOptions(encounter: RuntimeEncounter) {
return [
'那先继续聊聊你刚才没说完的部分',
'除了委托,你对眼前局势还有什么判断',
'先把这附近真正危险的地方说清楚',
].map((actionText) => buildNpcChatOption(encounter, actionText));
}
function buildQuestOfferDialogueText(
encounter: RuntimeEncounter,
quest: RuntimeQuestLogEntry,
) {
const summaryText = readString(quest.summary) || readString(quest.description);
return `${encounter.npcName}沉吟了片刻,像是终于把真正想托付的事说了出来。${
summaryText
? `如果你愿意,我想把这件事正式交给你:${summaryText}`
: '如果你愿意,我想把眼前这件事正式交给你。'
}`;
}
function ensureEncounterQuestContext(session: RuntimeSession) {
const state = session.rawGameState as unknown as RuntimeGameState;
const encounter = getNpcEncounter(session, state);
@@ -225,6 +370,171 @@ function resolveQuestAcceptAction(
};
}
function resolveQuestOfferViewAction(
session: RuntimeSession,
currentStory?: unknown,
): QuestStoryResolution {
const { encounter, npcKey } = ensureEncounterQuestContext(session);
const pendingOffer = readPendingQuestOfferContext(currentStory, npcKey);
if (!pendingOffer) {
throw conflict('当前没有待处理的委托可查看。');
}
return {
actionText: `查看${encounter.npcName}提出的委托`,
resultText: readString(pendingOffer.introText) || buildQuestOfferDialogueText(encounter, pendingOffer.quest),
patches: [],
};
}
function resolveQuestOfferReplaceAction(
session: RuntimeSession,
currentStory?: unknown,
): QuestStoryResolution {
const { state, encounter, npcKey } = ensureEncounterQuestContext(session);
const pendingOffer = readPendingQuestOfferContext(currentStory, npcKey);
if (!pendingOffer) {
throw conflict('当前没有待处理的委托可更换。');
}
const nextQuest = buildQuestForEncounter({
issuerNpcId: npcKey,
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene: state.currentScenePreset,
worldType: state.worldType,
context: {
worldType: state.worldType,
recentStoryMoments: Array.isArray(state.storyHistory)
? state.storyHistory.slice(-6)
: [],
playerCharacter: state.playerCharacter ?? null,
playerProgression: state.playerProgression ?? null,
},
currentQuests: (Array.isArray(state.quests) ? state.quests : []).map((item) => ({
id: item.id,
issuerNpcId: item.issuerNpcId,
status: item.status,
})),
});
if (!nextQuest) {
throw conflict('当前没有更合适的委托可供更换。');
}
const dialogue = [
...pendingOffer.dialogue,
{
speaker: 'player',
text: '能不能换一份更适合眼下局势的委托?',
},
{
speaker: 'npc',
speakerName: encounter.npcName,
text: buildQuestOfferDialogueText(encounter, nextQuest),
},
];
return {
actionText: `${encounter.npcName}更换委托`,
resultText: buildQuestOfferDialogueText(encounter, nextQuest),
storyText: buildQuestOfferDialogueText(encounter, nextQuest),
savedCurrentStory: {
text: dialogue
.map((entry) => readString(entry.text))
.filter(Boolean)
.join('\n'),
options: buildPendingQuestOfferOptions(encounter),
displayMode: 'dialogue',
dialogue,
streaming: false,
npcChatState: {
npcId: npcKey,
npcName: encounter.npcName,
turnCount: pendingOffer.turnCount,
customInputPlaceholder: pendingOffer.customInputPlaceholder,
pendingQuestOffer: {
quest: nextQuest,
},
},
},
presentationOptions: buildPendingQuestOfferOptions(encounter).map((option) => ({
functionId: readString(option.functionId),
actionText: readString(option.actionText),
detailText: '',
scope: 'npc',
interaction: isObject(option.interaction)
? (option.interaction as RuntimeStoryOptionView['interaction'])
: undefined,
payload: isObject(option.runtimePayload)
? (option.runtimePayload as Record<string, unknown>)
: undefined,
})),
patches: [],
};
}
function resolveQuestOfferAbandonAction(
session: RuntimeSession,
currentStory?: unknown,
): QuestStoryResolution {
const { encounter, npcKey } = ensureEncounterQuestContext(session);
const pendingOffer = readPendingQuestOfferContext(currentStory, npcKey);
if (!pendingOffer) {
throw conflict('当前没有待处理的委托可放弃。');
}
const npcReply = `${encounter.npcName}点了点头,没有继续强求,只把这份委托暂时收了回去。`;
const dialogue = [
...pendingOffer.dialogue,
{
speaker: 'player',
text: '这件事我先不接,咱们还是先聊别的。',
},
{
speaker: 'npc',
speakerName: encounter.npcName,
text: npcReply,
},
];
return {
actionText: `暂不接受${encounter.npcName}的委托`,
resultText: npcReply,
storyText: npcReply,
savedCurrentStory: {
text: dialogue
.map((entry) => readString(entry.text))
.filter(Boolean)
.join('\n'),
options: buildPostQuestOfferChatOptions(encounter),
displayMode: 'dialogue',
dialogue,
streaming: false,
npcChatState: {
npcId: npcKey,
npcName: encounter.npcName,
turnCount: pendingOffer.turnCount,
customInputPlaceholder: pendingOffer.customInputPlaceholder,
pendingQuestOffer: null,
},
},
presentationOptions: buildPostQuestOfferChatOptions(encounter).map((option) => ({
functionId: readString(option.functionId),
actionText: readString(option.actionText),
detailText: '',
scope: 'npc',
interaction: isObject(option.interaction)
? (option.interaction as RuntimeStoryOptionView['interaction'])
: undefined,
payload: isObject(option.runtimePayload)
? (option.runtimePayload as Record<string, unknown>)
: undefined,
})),
patches: [],
};
}
function resolveQuestTurnInAction(
session: RuntimeSession,
request: RuntimeStoryActionRequest,
@@ -311,6 +621,12 @@ export function resolveQuestStoryAction(
} = {},
): QuestStoryResolution {
switch (request.action.functionId) {
case 'npc_chat_quest_offer_view':
return resolveQuestOfferViewAction(session, options.currentStory);
case 'npc_chat_quest_offer_replace':
return resolveQuestOfferReplaceAction(session, options.currentStory);
case 'npc_chat_quest_offer_abandon':
return resolveQuestOfferAbandonAction(session, options.currentStory);
case 'npc_quest_accept':
return resolveQuestAcceptAction(session, options.currentStory);
case 'npc_quest_turn_in':

View File

@@ -4,7 +4,7 @@ import {
QUEST_OBJECTIVE_KINDS,
QUEST_REWARD_THEMES,
QUEST_URGENCY_LEVELS,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js';
import {
buildQuestIntentPrompt,
QUEST_INTENT_SYSTEM_PROMPT,

View File

@@ -0,0 +1,14 @@
import {
buildAvailableOptions,
buildLegacyCurrentStory,
buildRuntimeViewModel,
} from './RpgRuntimeSessionDomain.js';
/**
* RPG runtime option / view model 编译入口。
* 工作包 G 后所有可见 option 与 view model 都从新域目录输出。
*/
export { buildAvailableOptions, buildRuntimeViewModel };
export const buildRpgRuntimeAvailableOptions = buildAvailableOptions;
export const buildRpgRuntimeViewModel = buildRuntimeViewModel;
export const buildRpgRuntimeLegacyCurrentStory = buildLegacyCurrentStory;

View File

@@ -3,9 +3,13 @@ import test from 'node:test';
import {
buildAvailableOptions,
} from './RpgRuntimeOptionCompiler.js';
import {
buildLegacyCurrentStory,
} from './RpgRuntimeStoryPresentationCompiler.js';
import {
loadRuntimeSession,
} from './runtimeSession.ts';
} from './RpgRuntimeSessionLoader.js';
function createNpcSnapshot() {
return {

View File

@@ -1,3 +1,7 @@
/**
* RPG runtime session
* G `runtimeSession.ts`
*/
import type {
RuntimeStoryChoicePayload,
RuntimeStoryEncounterViewModel,
@@ -5,9 +9,9 @@ import type {
RuntimeStoryOptionView,
RuntimeStoryViewModel,
Task5RuntimeOptionScope,
} from '../../../../packages/shared/src/contracts/story.js';
import { TASK6_RUNTIME_FUNCTION_IDS } from '../../../../packages/shared/src/contracts/story.js';
import type { SavedSnapshot } from '../../repositories/runtimeRepository.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import { TASK6_RUNTIME_FUNCTION_IDS } from '../../../../packages/shared/src/contracts/rpgRuntimeStoryAction.js';
import type { RpgRuntimeSavedSnapshot } from '../../repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js';
import {
normalizeRuntimeEntityLevelProfile,
type RuntimeEntityLevelProfile,
@@ -75,6 +79,16 @@ export type RuntimeCompanion = {
npcId: string;
characterId: string;
joinedAtAffinity: number;
hp: number;
maxHp: number;
mana: number;
maxMana: number;
skillCooldowns: Record<string, number>;
animationState?: string;
actionMode?: string;
offsetX?: number;
offsetY?: number;
transitionMs?: number;
};
type RuntimePlayerAttributes = {
@@ -146,6 +160,7 @@ export type RuntimeSession = {
playerMaxMana: number;
npcStates: Record<string, RuntimeNpcState>;
companions: RuntimeCompanion[];
roster: RuntimeCompanion[];
currentNpcBattleMode: 'fight' | 'spar' | null;
currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null;
};
@@ -511,6 +526,53 @@ function normalizeCompanion(value: unknown): RuntimeCompanion | null {
npcId,
characterId: readString(rawCompanion.characterId),
joinedAtAffinity: Math.round(readNumber(rawCompanion.joinedAtAffinity, 0)),
hp: Math.max(
0,
Math.round(
readNumber(
rawCompanion.hp,
readNumber(rawCompanion.maxHp, 1),
),
),
),
maxHp: Math.max(1, Math.round(readNumber(rawCompanion.maxHp, 1))),
mana: Math.max(
0,
Math.round(
readNumber(
rawCompanion.mana,
readNumber(rawCompanion.maxMana, 1),
),
),
),
maxMana: Math.max(1, Math.round(readNumber(rawCompanion.maxMana, 1))),
skillCooldowns: Object.fromEntries(
Object.entries(
isObject(rawCompanion.skillCooldowns)
? rawCompanion.skillCooldowns
: {},
).map(([skillId, turns]) => [
skillId,
Math.max(0, Math.round(readNumber(turns, 0))),
]),
),
animationState: readString(rawCompanion.animationState) || undefined,
actionMode: readString(rawCompanion.actionMode) || undefined,
offsetX:
typeof rawCompanion.offsetX === 'number' &&
Number.isFinite(rawCompanion.offsetX)
? rawCompanion.offsetX
: undefined,
offsetY:
typeof rawCompanion.offsetY === 'number' &&
Number.isFinite(rawCompanion.offsetY)
? rawCompanion.offsetY
: undefined,
transitionMs:
typeof rawCompanion.transitionMs === 'number' &&
Number.isFinite(rawCompanion.transitionMs)
? Math.max(0, Math.round(rawCompanion.transitionMs))
: undefined,
};
}
@@ -531,6 +593,15 @@ function normalizeCompanions(value: unknown) {
.filter((entry): entry is RuntimeCompanion => Boolean(entry));
}
function normalizeRoster(
roster: RuntimeCompanion[],
companions: RuntimeCompanion[],
) {
const activeNpcIds = new Set(companions.map((companion) => companion.npcId));
return roster.filter((companion) => !activeNpcIds.has(companion.npcId));
}
function normalizeHostileNpcs(value: unknown) {
return readArray(value)
.map((entry) => normalizeHostileNpc(entry))
@@ -738,6 +809,21 @@ function buildOptionInteraction(
npc_spar: { kind: 'npc', npcId, action: 'spar' },
npc_trade: { kind: 'npc', npcId, action: 'trade' },
npc_gift: { kind: 'npc', npcId, action: 'gift' },
npc_chat_quest_offer_view: {
kind: 'npc',
npcId,
action: 'quest_offer_view',
},
npc_chat_quest_offer_replace: {
kind: 'npc',
npcId,
action: 'quest_offer_replace',
},
npc_chat_quest_offer_abandon: {
kind: 'npc',
npcId,
action: 'quest_offer_abandon',
},
npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' },
npc_quest_turn_in: { kind: 'npc', npcId, action: 'quest_turn_in' },
};
@@ -929,7 +1015,7 @@ export function getEncounterKey(encounter: RuntimeEncounter) {
}
export function loadRuntimeSession(
snapshot: SavedSnapshot,
snapshot: RpgRuntimeSavedSnapshot,
requestedSessionId: string,
): RuntimeSession {
const rawGameState = isObject(snapshot.gameState)
@@ -967,6 +1053,10 @@ export function loadRuntimeSession(
),
npcStates: normalizeNpcStates(rawGameState.npcStates),
companions: normalizeCompanions(rawGameState.companions),
roster: normalizeRoster(
normalizeCompanions(rawGameState.roster),
normalizeCompanions(rawGameState.companions),
),
currentNpcBattleMode:
rawGameState.currentNpcBattleMode === 'fight' ||
rawGameState.currentNpcBattleMode === 'spar'
@@ -1170,16 +1260,7 @@ export function buildAvailableOptions(session: RuntimeSession) {
if (npcState && !npcState.recruited && npcState.affinity >= 60) {
options.push(
buildOptionView(
session,
'npc_recruit',
session.companions.length >= MAX_TASK5_COMPANIONS
? {
disabled: true,
reason: '队伍已满任务5首轮后端接口暂不处理换队逻辑。',
}
: {},
),
buildOptionView(session, 'npc_recruit'),
);
}
@@ -1313,6 +1394,7 @@ export function syncRawGameState(session: RuntimeSession) {
session.rawGameState.playerMaxMana = session.playerMaxMana;
session.rawGameState.npcStates = cloneJson(session.npcStates);
session.rawGameState.companions = cloneJson(session.companions);
session.rawGameState.roster = cloneJson(session.roster);
session.rawGameState.currentNpcBattleMode = session.currentNpcBattleMode;
session.rawGameState.currentNpcBattleOutcome =
session.currentNpcBattleOutcome;
@@ -1352,6 +1434,7 @@ export function replaceRuntimeSessionRawGameState(
session.playerMaxMana = refreshed.playerMaxMana;
session.npcStates = refreshed.npcStates;
session.companions = refreshed.companions;
session.roster = refreshed.roster;
session.currentNpcBattleMode = refreshed.currentNpcBattleMode;
session.currentNpcBattleOutcome = refreshed.currentNpcBattleOutcome;
}

View File

@@ -0,0 +1,13 @@
import {
loadRuntimeSession,
type RuntimeSession,
} from './RpgRuntimeSessionDomain.js';
export type { RuntimeSession };
/**
* RPG runtime session loader 的主入口。
* 工作包 G 把旧 `runtimeSession.ts` 的真实实现迁入新域后,这里负责承接稳定命名。
*/
export { loadRuntimeSession };
export const loadRpgRuntimeSession = loadRuntimeSession;

View File

@@ -0,0 +1,29 @@
/**
* RPG runtime session 原子能力导出。
* 这里集中输出运行时动作链直接依赖的 session 原语,避免再次回到旧热点文件取用。
*/
export {
appendStoryHistory,
getEncounterKey,
getEncounterNpcState,
getPlayerCharacter,
getPlayerSkillCooldowns,
isCombatFunctionId,
isNpcFunctionId,
isStoryFunctionId,
isTask5FunctionId,
isTask6RuntimeFunctionId,
MAX_TASK5_COMPANIONS,
setEncounterNpcState,
syncRawGameState,
TASK6_DEFERRED_FUNCTION_IDS,
} from './RpgRuntimeSessionDomain.js';
export type {
RuntimeCompanion,
RuntimeEncounter,
RuntimeHostileNpc,
RuntimeNpcState,
RuntimeSession,
RuntimeStoryHistoryEntry,
} from './RpgRuntimeSessionDomain.js';

View File

@@ -0,0 +1,13 @@
import {
replaceRuntimeSessionRawGameState,
syncRawGameState,
} from './RpgRuntimeSessionDomain.js';
/**
* RPG runtime snapshot 同步入口。
* 工作包 G 后 rawGameState 的回写与替换都统一从新域目录输出。
*/
export { replaceRuntimeSessionRawGameState, syncRawGameState };
export const syncRpgRuntimeSnapshot = syncRawGameState;
export const replaceRpgRuntimeSessionRawGameState =
replaceRuntimeSessionRawGameState;

View File

@@ -1,12 +1,17 @@
/**
* RPG runtime story /
* G RPG runtime story
*/
import type {
RuntimeBattlePresentation,
RuntimeStoryActionRequest,
RuntimeStoryActionResponse,
RuntimeStoryOptionView,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
RuntimeStoryStateRequest,
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import { conflict, invalidRequest } from '../../errors.js';
import type { RuntimeRepositoryPort } from '../../repositories/runtimeRepository.js';
import type { RpgRuntimeSnapshotRepositoryPort } from '../../repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js';
import {
buildStrictNpcChatDialoguePrompt,
@@ -38,27 +43,35 @@ import {
resolveTreasureStoryAction,
} from '../runtime-item/treasureStoryActionService.js';
import {
appendStoryHistory,
buildAvailableOptions,
buildLegacyCurrentStory,
buildRuntimeViewModel,
} from './RpgRuntimeOptionCompiler.js';
import {
appendStoryHistory,
getEncounterNpcState,
isCombatFunctionId,
isNpcFunctionId,
isStoryFunctionId,
isTask5FunctionId,
loadRuntimeSession,
type RuntimeSession,
setEncounterNpcState,
syncRawGameState,
TASK6_DEFERRED_FUNCTION_IDS,
} from './runtimeSession.js';
} from './RpgRuntimeSessionPrimitives.js';
import {
buildLegacyCurrentStory,
} from './RpgRuntimeStoryPresentationCompiler.js';
import {
loadRuntimeSession,
type RuntimeSession,
} from './RpgRuntimeSessionLoader.js';
type StoryResolution = {
actionText: string;
resultText: string;
patches: RuntimeStoryPatch[];
storyText?: string;
presentationOptions?: RuntimeStoryOptionView[];
savedCurrentStory?: JsonRecord;
battle?: RuntimeBattlePresentation | null;
toast?: string | null;
};
@@ -604,6 +617,53 @@ function readSavedStoryText(currentStory: unknown) {
return '';
}
function normalizeIncomingSnapshot(snapshot: unknown) {
if (!isObject(snapshot)) {
return null;
}
const gameState = 'gameState' in snapshot ? snapshot.gameState : null;
const bottomTab = readString(snapshot.bottomTab) || 'adventure';
const currentStory = 'currentStory' in snapshot ? snapshot.currentStory : null;
const savedAt = readString(snapshot.savedAt) || new Date().toISOString();
if (!gameState || !isObject(gameState)) {
return null;
}
return normalizeSavedSnapshotPayload({
savedAt,
bottomTab,
gameState,
currentStory: currentStory ?? null,
});
}
async function resolveSnapshotForRequest(params: {
snapshotRepository: RpgRuntimeSnapshotRepositoryPort;
userId: string;
snapshot?: unknown;
}) {
const incomingSnapshot = normalizeIncomingSnapshot(params.snapshot);
if (incomingSnapshot) {
return hydrateSavedSnapshot(
await params.snapshotRepository.putSnapshot(
params.userId,
incomingSnapshot,
),
)!;
}
const persistedSnapshot = await params.snapshotRepository.getSnapshot(
params.userId,
);
if (!persistedSnapshot) {
throw conflict('运行时快照不存在,请先初始化并保存一次游戏');
}
return hydrateSavedSnapshot(persistedSnapshot)!;
}
function buildFallbackStoryText(session: RuntimeSession) {
if (session.inBattle && session.sceneHostileNpcs.length > 0) {
return `眼前的冲突还没结束,${session.sceneHostileNpcs[0]!.name}仍在逼你立刻做出下一步判断。`;
@@ -855,16 +915,16 @@ function resolveStoryFlowAction(
}
export async function resolveRuntimeStoryAction(params: {
runtimeRepository: RuntimeRepositoryPort;
snapshotRepository: RpgRuntimeSnapshotRepositoryPort;
llmClient?: UpstreamLlmClient;
userId: string;
request: RuntimeStoryActionRequest;
}) {
const snapshot = await params.runtimeRepository.getSnapshot(params.userId);
if (!snapshot) {
throw conflict('运行时快照不存在,请先初始化并保存一次游戏');
}
const hydratedSnapshot = hydrateSavedSnapshot(snapshot)!;
const hydratedSnapshot = await resolveSnapshotForRequest({
snapshotRepository: params.snapshotRepository,
userId: params.userId,
snapshot: params.request.snapshot,
});
const functionId =
typeof params.request.action.functionId === 'string'
@@ -924,7 +984,13 @@ export async function resolveRuntimeStoryAction(params: {
: undefined,
});
} else if (isNpcFunctionId(functionId)) {
resolution = resolveNpcInteraction(session, functionId);
resolution = resolveNpcInteraction(
session,
functionId,
isObject(params.request.action.payload)
? params.request.action.payload
: undefined,
);
} else if (isSupportedInventoryStoryFunctionId(functionId)) {
resolution = resolveInventoryStoryAction(session, params.request);
} else if (isSupportedNpcInventoryStoryFunctionId(functionId)) {
@@ -968,6 +1034,12 @@ export async function resolveRuntimeStoryAction(params: {
storyText,
options,
);
if (resolution.presentationOptions?.length) {
options = resolution.presentationOptions;
}
if (resolution.savedCurrentStory) {
savedCurrentStory = resolution.savedCurrentStory;
}
const pendingQuestAcceptedCurrentStory =
functionId === 'npc_quest_accept'
? buildPendingQuestAcceptedCurrentStory({
@@ -1023,7 +1095,7 @@ export async function resolveRuntimeStoryAction(params: {
appendStoryHistory(session, actionText, historyResultText);
syncRawGameState(session);
const persistedSnapshot = await params.runtimeRepository.putSnapshot(
const persistedSnapshot = await params.snapshotRepository.putSnapshot(
params.userId,
normalizeSavedSnapshotPayload({
savedAt: new Date().toISOString(),
@@ -1058,17 +1130,28 @@ export async function resolveRuntimeStoryAction(params: {
}
export async function getRuntimeStoryState(params: {
runtimeRepository: RuntimeRepositoryPort;
snapshotRepository: RpgRuntimeSnapshotRepositoryPort;
userId: string;
sessionId: string;
clientVersion?: number;
snapshot?: RuntimeStoryStateRequest['snapshot'];
}) {
const snapshot = await params.runtimeRepository.getSnapshot(params.userId);
if (!snapshot) {
throw conflict('运行时快照不存在,请先初始化并保存一次游戏');
}
const hydratedSnapshot = hydrateSavedSnapshot(snapshot)!;
const hydratedSnapshot = await resolveSnapshotForRequest({
snapshotRepository: params.snapshotRepository,
userId: params.userId,
snapshot: params.snapshot,
});
const session = loadRuntimeSession(hydratedSnapshot, params.sessionId);
if (
typeof params.clientVersion === 'number' &&
params.clientVersion !== session.runtimeVersion
) {
throw conflict('运行时版本已变化,请先同步最新快照后再读取状态', {
clientVersion: params.clientVersion,
serverVersion: session.runtimeVersion,
});
}
ensureNpcInventorySessionState(session);
const options = buildAvailableOptions(session);
const storyText =

View File

@@ -0,0 +1,10 @@
import {
resolveRuntimeStoryAction,
} from './RpgRuntimeStoryActionDomain.js';
/**
* RPG runtime story 动作服务入口。
* 工作包 G 后 runtime action 主链直接落到新域实现,不再继续桥接旧热点文件。
*/
export { resolveRuntimeStoryAction };
export const resolveRpgRuntimeStoryAction = resolveRuntimeStoryAction;

View File

@@ -0,0 +1,8 @@
import { buildLegacyCurrentStory } from './RpgRuntimeSessionDomain.js';
/**
* RPG runtime story 展示兼容编译器。
* 当前仍复用 legacy currentStory 投影规则,但真实落点已经切到新域目录。
*/
export { buildLegacyCurrentStory };
export const buildRpgRuntimeLegacyCurrentStory = buildLegacyCurrentStory;

View File

@@ -0,0 +1,8 @@
import { getRuntimeStoryState } from './RpgRuntimeStoryActionDomain.js';
/**
* RPG runtime story 状态读取入口。
* 工作包 G 先把状态读取从旧热点文件迁到新域命名,再保持动作与状态的物理落点分离。
*/
export { getRuntimeStoryState };
export const getRpgRuntimeStoryState = getRuntimeStoryState;

View File

@@ -1,2 +0,0 @@
export * from './runtimeItemResolutionService.js';
export { generateRuntimeItemIntents } from '../../services/runtimeItemService.js';

View File

@@ -1,7 +1,7 @@
import {
RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES,
RUNTIME_ITEM_TONE_VALUES,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js';
import {
buildRuntimeItemIntentPromptText,
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,

View File

@@ -1,96 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildLooseRuntimeItemGenerationContext,
buildQuestRuntimeItemGenerationContext,
} from '../../bridges/legacyRuntimeItemResolutionBridge.js';
import {
resolveDirectedReward,
resolveRuntimeInventoryStock,
} from './runtimeItemResolutionService.js';
const TEST_WUXIA_WORLD = 'WUXIA' as Parameters<
typeof buildLooseRuntimeItemGenerationContext
>[0]['worldType'];
const TEST_XIANXIA_WORLD = 'XIANXIA' as NonNullable<
Parameters<typeof buildQuestRuntimeItemGenerationContext>[0]['context']['worldType']
>;
test('resolveDirectedReward returns flattened runtime reward items on the server side', () => {
const context = buildLooseRuntimeItemGenerationContext({
worldType: TEST_WUXIA_WORLD,
scene: {
id: 'scene-ruins',
name: '断碑古道',
description: '碎碑与旧誓散落在路旁。',
treasureHints: ['残匣', '旧祭火'],
},
encounter: {
id: 'treasure-altar',
kind: 'treasure',
npcName: '断誓秘匣',
npcDescription: '匣盖上留着未熄的旧印。',
npcAvatar: '',
context: '古道祭坛',
},
playerCharacterId: 'hero',
playerBuildTags: ['快剑', '追击'],
generationChannel: 'treasure',
});
const result = resolveDirectedReward(context, {
seedKey: 'task6:treasure',
fixedKinds: ['relic', 'consumable'],
fixedPermanence: ['permanent', 'timed'],
itemCount: 2,
});
assert.equal(result.items.length, 2);
assert.equal(
result.reward.primaryItem?.runtimeMetadata?.generationChannel,
'treasure',
);
assert.equal(result.items[0]?.id, result.reward.primaryItem?.id);
assert.ok(result.reward.primaryItem?.description?.includes('构筑'));
});
test('resolveRuntimeInventoryStock builds quest-flavored stock without browser fallback', () => {
const context = buildQuestRuntimeItemGenerationContext({
context: {
worldType: TEST_XIANXIA_WORLD,
currentSceneId: 'scene-cloud',
currentSceneName: '云阙旧渡',
currentSceneDescription: '旧渡口残留着灵潮和巡守痕迹。',
issuerNpcId: 'npc-issuer',
issuerNpcName: '巡守使',
issuerNpcContext: '巡守',
issuerAffinity: 24,
recentStoryMoments: [],
playerCharacter: null,
},
issuerNpcId: 'npc-issuer',
issuerNpcName: '巡守使',
roleText: '巡守',
scene: {
id: 'scene-cloud',
name: '云阙旧渡',
description: '旧渡口残留着灵潮和巡守痕迹。',
treasureHints: ['旧印'],
},
});
const items = resolveRuntimeInventoryStock(context, {
seedKey: 'task6:quest',
fixedKinds: ['equipment', 'consumable'],
fixedPermanence: ['permanent', 'timed'],
itemCount: 2,
});
assert.equal(items.length, 2);
assert.equal(
items.every((item) => item.runtimeMetadata?.generationChannel === 'quest_reward'),
true,
);
assert.equal(items.some((item) => Boolean(item.buildProfile || item.useProfile)), true);
});

View File

@@ -1,39 +0,0 @@
import {
buildDirectedRuntimeReward,
buildRuntimeInventoryStock,
flattenDirectedRuntimeRewardItems,
} from '../../bridges/legacyRuntimeItemResolutionBridge.js';
export type RuntimeItemGenerationContext = Parameters<
typeof buildDirectedRuntimeReward
>[0];
export type RuntimeRewardOptions = Parameters<
typeof buildDirectedRuntimeReward
>[1];
export type DirectedRuntimeReward = ReturnType<typeof buildDirectedRuntimeReward>;
export type ResolvedRuntimeRewardItem = ReturnType<
typeof buildRuntimeInventoryStock
>[number];
export type RuntimeRewardResolution = {
reward: DirectedRuntimeReward;
items: ResolvedRuntimeRewardItem[];
};
export function resolveDirectedReward(
context: RuntimeItemGenerationContext,
options: RuntimeRewardOptions,
): RuntimeRewardResolution {
const reward = buildDirectedRuntimeReward(context, options);
return {
reward,
items: flattenDirectedRuntimeRewardItems(reward),
};
}
export function resolveRuntimeInventoryStock(
context: RuntimeItemGenerationContext,
options: RuntimeRewardOptions,
): ResolvedRuntimeRewardItem[] {
return buildRuntimeInventoryStock(context, options);
}

View File

@@ -1,7 +1,7 @@
import type {
RuntimeStoryActionRequest,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
import { conflict, invalidRequest } from '../../errors.js';
import {
addInventoryItems,
@@ -14,8 +14,8 @@ import {
import { buildBuildToast } from '../inventory/inventoryStoryActionService.js';
import {
replaceRuntimeSessionRawGameState,
type RuntimeSession,
} from '../story/runtimeSession.js';
} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js';
import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js';
const SUPPORTED_TREASURE_STORY_FUNCTION_IDS = new Set<string>([
'treasure_inspect',

View File

@@ -93,6 +93,11 @@ function createTestConfig(testName: string): AppConfig {
mockAvatarUrl: '',
},
authSession: {
accessCookieName: 'genarrative_access_session',
accessCookieTtlSeconds: 7200,
accessCookieSecure: false,
accessCookieSameSite: 'Lax',
accessCookiePath: '/',
refreshCookieName: 'genarrative_refresh_session',
refreshSessionTtlDays: 30,
refreshCookieSecure: false,

View File

@@ -5,7 +5,7 @@ import type {
NpcChatDialogueRequest,
NpcChatTurnRequest,
NpcRecruitDialogueRequest,
} from '../../../packages/shared/src/contracts/story.js';
} from '../../../packages/shared/src/contracts/rpgRuntimeChat.js';
type JsonRecord = Record<string, unknown>;

View File

@@ -0,0 +1,100 @@
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { AppDatabase } from '../db.js';
import {
type RpgAgentSessionRow,
} from './rpgWorldRepositoryShared.js';
/**
* RPG Agent session 仓储最小读写接口。
* 工作包 F 后续所有 session store / test stub 都应优先依赖这个领域端口。
*/
export type RpgAgentSessionRepositoryPort = {
listSessions(userId: string): Promise<CustomWorldSessionRecord[]>;
getSession(
userId: string,
sessionId: string,
): Promise<CustomWorldSessionRecord | null>;
upsertSession(
userId: string,
sessionId: string,
session: CustomWorldSessionRecord,
): Promise<CustomWorldSessionRecord>;
};
/**
* RPG Agent session 仓储只负责 session 表读写,不再承担兼容补齐与快照派生。
*/
export class RpgAgentSessionRepository implements RpgAgentSessionRepositoryPort {
constructor(private readonly db: AppDatabase) {}
async listSessions(userId: string) {
const result = await this.db.query<RpgAgentSessionRow>(
`SELECT payload_json AS payload,
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM custom_world_sessions
WHERE user_id = $1
ORDER BY updated_at DESC`,
[userId],
);
return result.rows.map((row) => ({
...row.payload,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}));
}
async getSession(userId: string, sessionId: string) {
const result = await this.db.query<RpgAgentSessionRow>(
`SELECT payload_json AS payload,
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM custom_world_sessions
WHERE user_id = $1 AND session_id = $2`,
[userId, sessionId],
);
const row = result.rows[0];
if (!row) {
return null;
}
return {
...row.payload,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
async upsertSession(
userId: string,
sessionId: string,
session: CustomWorldSessionRecord,
) {
const payload = {
...session,
sessionId,
} satisfies CustomWorldSessionRecord;
await this.db.query(
`INSERT INTO custom_world_sessions (
user_id,
session_id,
payload_json,
created_at,
updated_at
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, session_id) DO UPDATE SET
payload_json = EXCLUDED.payload_json,
updated_at = EXCLUDED.updated_at`,
[userId, sessionId, payload, session.createdAt, session.updatedAt],
);
return {
...payload,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
};
}
}

View File

@@ -0,0 +1,433 @@
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
CustomWorldProfileRecord,
} from '../../../packages/shared/src/contracts/runtime.js';
import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js';
import type { AppDatabase } from '../db.js';
import {
MAX_RPG_WORLD_GALLERY_ENTRIES,
MAX_RPG_WORLD_PROFILE_ENTRIES,
normalizeStoredRpgWorldProfile,
toRpgWorldGalleryCard,
toRpgWorldLibraryEntry,
type RpgWorldGalleryRow,
type RpgWorldProfileRow,
} from './rpgWorldRepositoryShared.js';
/**
* RPG 世界 profile 领域端口。
* works、library、gallery、脚本同步等链路后续统一依赖这个接口而不是 RuntimeRepositoryPort。
*/
export type RpgWorldProfileRepositoryPort = {
listOwnProfiles(
userId: string,
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]>;
upsertOwnProfile(
userId: string,
profileId: string,
profile: Record<string, unknown>,
authorDisplayName: string,
): Promise<{
entry: CustomWorldLibraryEntry<CustomWorldProfileRecord>;
entries: CustomWorldLibraryEntry<CustomWorldProfileRecord>[];
}>;
syncProfileFromSnapshot(
userId: string,
profileId: string,
profile: Record<string, unknown>,
syncedAt: string,
): Promise<void>;
softDeleteOwnProfile(
userId: string,
profileId: string,
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]>;
publishOwnProfile(
userId: string,
profileId: string,
authorDisplayName: string,
): Promise<{
entry: CustomWorldLibraryEntry<CustomWorldProfileRecord>;
entries: CustomWorldLibraryEntry<CustomWorldProfileRecord>[];
} | null>;
unpublishOwnProfile(
userId: string,
profileId: string,
authorDisplayName: string,
): Promise<{
entry: CustomWorldLibraryEntry<CustomWorldProfileRecord>;
entries: CustomWorldLibraryEntry<CustomWorldProfileRecord>[];
} | null>;
listPublishedGallery(): Promise<CustomWorldGalleryCard[]>;
getPublishedGalleryDetail(
ownerUserId: string,
profileId: string,
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord> | null>;
};
/**
* RPG 世界 profile 仓储统一负责作品库、发布态与画廊读写。
*/
export class RpgWorldProfileRepository implements RpgWorldProfileRepositoryPort {
constructor(private readonly db: AppDatabase) {}
private async findOwnProfileEntry(userId: string, profileId: string) {
const result = await this.db.query<RpgWorldProfileRow>(
`SELECT user_id AS "ownerUserId",
profile_id AS "profileId",
payload_json AS payload,
visibility,
published_at AS "publishedAt",
updated_at AS "updatedAt",
author_display_name AS "authorDisplayName",
world_name AS "worldName",
subtitle,
summary_text AS "summaryText",
cover_image_src AS "coverImageSrc",
theme_mode AS "themeMode",
playable_npc_count AS "playableNpcCount",
landmark_count AS "landmarkCount"
FROM custom_world_profiles
WHERE user_id = $1
AND profile_id = $2
AND deleted_at IS NULL`,
[userId, profileId],
);
const row = result.rows[0];
return row ? toRpgWorldLibraryEntry(row) : null;
}
async listOwnProfiles(userId: string) {
const result = await this.db.query<RpgWorldProfileRow>(
`SELECT user_id AS "ownerUserId",
profile_id AS "profileId",
payload_json AS payload,
visibility,
published_at AS "publishedAt",
updated_at AS "updatedAt",
author_display_name AS "authorDisplayName",
world_name AS "worldName",
subtitle,
summary_text AS "summaryText",
cover_image_src AS "coverImageSrc",
theme_mode AS "themeMode",
playable_npc_count AS "playableNpcCount",
landmark_count AS "landmarkCount"
FROM custom_world_profiles
WHERE user_id = $1
AND deleted_at IS NULL
ORDER BY updated_at DESC
LIMIT $2`,
[userId, MAX_RPG_WORLD_PROFILE_ENTRIES],
);
return result.rows.map((row) => toRpgWorldLibraryEntry(row));
}
async upsertOwnProfile(
userId: string,
profileId: string,
profile: Record<string, unknown>,
authorDisplayName: string,
) {
const payload = normalizeStoredRpgWorldProfile(profileId, profile);
const metadata = extractCustomWorldLibraryMetadata(payload);
const now = new Date().toISOString();
await this.db.query(
`INSERT INTO custom_world_profiles (
user_id,
profile_id,
payload_json,
updated_at,
author_display_name,
world_name,
subtitle,
summary_text,
cover_image_src,
theme_mode,
playable_npc_count,
landmark_count
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (user_id, profile_id) DO UPDATE SET
payload_json = EXCLUDED.payload_json,
updated_at = EXCLUDED.updated_at,
deleted_at = NULL,
author_display_name = EXCLUDED.author_display_name,
world_name = EXCLUDED.world_name,
subtitle = EXCLUDED.subtitle,
summary_text = EXCLUDED.summary_text,
cover_image_src = EXCLUDED.cover_image_src,
theme_mode = EXCLUDED.theme_mode,
playable_npc_count = EXCLUDED.playable_npc_count,
landmark_count = EXCLUDED.landmark_count`,
[
userId,
profileId,
payload,
now,
authorDisplayName || '玩家',
metadata.worldName,
metadata.subtitle,
metadata.summaryText,
metadata.coverImageSrc,
metadata.themeMode,
metadata.playableNpcCount,
metadata.landmarkCount,
],
);
const entry = await this.findOwnProfileEntry(userId, profileId);
if (!entry) {
throw new Error('failed to resolve custom world after upsert');
}
return {
entry,
entries: await this.listOwnProfiles(userId),
};
}
async syncProfileFromSnapshot(
userId: string,
profileId: string,
profile: Record<string, unknown>,
syncedAt: string,
) {
const payload = normalizeStoredRpgWorldProfile(profileId, profile);
const metadata = extractCustomWorldLibraryMetadata(payload);
await this.db.query(
`INSERT INTO custom_world_profiles (
user_id,
profile_id,
payload_json,
updated_at,
author_display_name,
world_name,
subtitle,
summary_text,
cover_image_src,
theme_mode,
playable_npc_count,
landmark_count,
deleted_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULL)
ON CONFLICT (user_id, profile_id) DO UPDATE SET
payload_json = EXCLUDED.payload_json,
updated_at = EXCLUDED.updated_at,
deleted_at = NULL,
world_name = EXCLUDED.world_name,
subtitle = EXCLUDED.subtitle,
summary_text = EXCLUDED.summary_text,
cover_image_src = EXCLUDED.cover_image_src,
theme_mode = EXCLUDED.theme_mode,
playable_npc_count = EXCLUDED.playable_npc_count,
landmark_count = EXCLUDED.landmark_count`,
[
userId,
profileId,
payload,
syncedAt,
'玩家',
metadata.worldName,
metadata.subtitle,
metadata.summaryText,
metadata.coverImageSrc,
metadata.themeMode,
metadata.playableNpcCount,
metadata.landmarkCount,
],
);
}
async softDeleteOwnProfile(userId: string, profileId: string) {
const deletedAt = new Date().toISOString();
await this.db.query(
`UPDATE custom_world_profiles
SET deleted_at = $1,
updated_at = $1,
visibility = 'draft',
published_at = NULL
WHERE user_id = $2
AND profile_id = $3
AND deleted_at IS NULL`,
[deletedAt, userId, profileId],
);
return this.listOwnProfiles(userId);
}
async publishOwnProfile(
userId: string,
profileId: string,
authorDisplayName: string,
) {
const existingEntry = await this.findOwnProfileEntry(userId, profileId);
if (!existingEntry) {
return null;
}
const payload = normalizeStoredRpgWorldProfile(
profileId,
existingEntry.profile,
);
const metadata = extractCustomWorldLibraryMetadata(payload);
const now = new Date().toISOString();
await this.db.query(
`UPDATE custom_world_profiles
SET visibility = 'published',
published_at = $1,
updated_at = $1,
author_display_name = $2,
world_name = $3,
subtitle = $4,
summary_text = $5,
cover_image_src = $6,
theme_mode = $7,
playable_npc_count = $8,
landmark_count = $9
WHERE user_id = $10
AND profile_id = $11`,
[
now,
authorDisplayName || '玩家',
metadata.worldName,
metadata.subtitle,
metadata.summaryText,
metadata.coverImageSrc,
metadata.themeMode,
metadata.playableNpcCount,
metadata.landmarkCount,
userId,
profileId,
],
);
const entry = await this.findOwnProfileEntry(userId, profileId);
if (!entry) {
throw new Error('failed to resolve custom world after publish');
}
return {
entry,
entries: await this.listOwnProfiles(userId),
};
}
async unpublishOwnProfile(
userId: string,
profileId: string,
authorDisplayName: string,
) {
const existingEntry = await this.findOwnProfileEntry(userId, profileId);
if (!existingEntry) {
return null;
}
const payload = normalizeStoredRpgWorldProfile(
profileId,
existingEntry.profile,
);
const metadata = extractCustomWorldLibraryMetadata(payload);
const now = new Date().toISOString();
await this.db.query(
`UPDATE custom_world_profiles
SET visibility = 'draft',
published_at = NULL,
updated_at = $1,
author_display_name = $2,
world_name = $3,
subtitle = $4,
summary_text = $5,
cover_image_src = $6,
theme_mode = $7,
playable_npc_count = $8,
landmark_count = $9
WHERE user_id = $10
AND profile_id = $11`,
[
now,
authorDisplayName || '玩家',
metadata.worldName,
metadata.subtitle,
metadata.summaryText,
metadata.coverImageSrc,
metadata.themeMode,
metadata.playableNpcCount,
metadata.landmarkCount,
userId,
profileId,
],
);
const entry = await this.findOwnProfileEntry(userId, profileId);
if (!entry) {
throw new Error('failed to resolve custom world after unpublish');
}
return {
entry,
entries: await this.listOwnProfiles(userId),
};
}
async listPublishedGallery() {
const result = await this.db.query<RpgWorldGalleryRow>(
`SELECT user_id AS "ownerUserId",
profile_id AS "profileId",
visibility,
published_at AS "publishedAt",
updated_at AS "updatedAt",
author_display_name AS "authorDisplayName",
world_name AS "worldName",
subtitle,
summary_text AS "summaryText",
cover_image_src AS "coverImageSrc",
theme_mode AS "themeMode",
playable_npc_count AS "playableNpcCount",
landmark_count AS "landmarkCount"
FROM custom_world_profiles
WHERE visibility = 'published'
AND deleted_at IS NULL
ORDER BY published_at DESC, updated_at DESC
LIMIT $1`,
[MAX_RPG_WORLD_GALLERY_ENTRIES],
);
return result.rows.map((row) => toRpgWorldGalleryCard(row));
}
async getPublishedGalleryDetail(ownerUserId: string, profileId: string) {
const result = await this.db.query<RpgWorldProfileRow>(
`SELECT user_id AS "ownerUserId",
profile_id AS "profileId",
payload_json AS payload,
visibility,
published_at AS "publishedAt",
updated_at AS "updatedAt",
author_display_name AS "authorDisplayName",
world_name AS "worldName",
subtitle,
summary_text AS "summaryText",
cover_image_src AS "coverImageSrc",
theme_mode AS "themeMode",
playable_npc_count AS "playableNpcCount",
landmark_count AS "landmarkCount"
FROM custom_world_profiles
WHERE user_id = $1
AND profile_id = $2
AND visibility = 'published'
AND deleted_at IS NULL`,
[ownerUserId, profileId],
);
const row = result.rows[0];
return row ? toRpgWorldLibraryEntry(row) : null;
}
}

View File

@@ -0,0 +1,241 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { RuntimeRepositoryPort } from '../runtimeRepository.js';
import { RpgSaveArchiveRepository } from './RpgSaveArchiveRepository.js';
import { RpgWorldLibraryRepository } from './RpgWorldLibraryRepository.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
return {
async getSnapshot() {
return null;
},
async putSnapshot(_userId, payload) {
return {
version: 1,
...payload,
};
},
async getProfileDashboard() {
return {
walletBalance: 0,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-04-21T00:00:00.000Z',
};
},
async listProfileWalletLedger() {
return [];
},
async getProfilePlayStats() {
return {
totalPlayTimeMs: 0,
playedWorks: [],
updatedAt: '2026-04-21T00:00:00.000Z',
};
},
async listProfileSaveArchives() {
return [
{
worldKey: 'world-1',
ownerUserId: 'owner-1',
profileId: 'profile-1',
worldType: 'custom',
worldName: '潮影群岛',
subtitle: '港雾与旧航道',
summaryText: '最近一次继续游戏入口',
coverImageSrc: null,
lastPlayedAt: '2026-04-20T23:59:59.000Z',
},
];
},
async resumeProfileSaveArchive() {
return {
entry: {
worldKey: 'world-1',
ownerUserId: 'owner-1',
profileId: 'profile-1',
worldType: 'custom',
worldName: '潮影群岛',
subtitle: '港雾与旧航道',
summaryText: '最近一次继续游戏入口',
coverImageSrc: null,
lastPlayedAt: '2026-04-20T23:59:59.000Z',
},
snapshot: {
version: 1,
savedAt: '2026-04-20T23:59:59.000Z',
bottomTab: 'adventure',
gameState: { currentScene: '潮影港' },
currentStory: null,
},
};
},
async deleteSnapshot() {},
async getSettings() {
return {
musicVolume: 0.42,
platformTheme: 'light',
};
},
async putSettings(_userId, settings) {
return settings;
},
async listCustomWorldProfiles() {
return [
{
ownerUserId: 'owner-1',
profileId: 'profile-1',
profile: {
id: 'profile-1',
},
visibility: 'published',
publishedAt: '2026-04-20T08:00:00.000Z',
updatedAt: '2026-04-20T08:00:00.000Z',
authorDisplayName: '造物者',
worldName: '潮影群岛',
subtitle: '港雾与旧航道',
summaryText: '一座在潮汐中漂移的群岛。',
coverImageSrc: '/covers/tide.png',
themeMode: 'tide',
playableNpcCount: 2,
landmarkCount: 3,
},
];
},
async listPlatformBrowseHistory() {
return [];
},
async upsertPlatformBrowseHistoryEntries() {
return [];
},
async clearPlatformBrowseHistory() {},
async upsertCustomWorldProfile() {
return {
entry: {
ownerUserId: 'owner-1',
profileId: 'profile-1',
profile: {
id: 'profile-1',
},
visibility: 'draft',
publishedAt: null,
updatedAt: '2026-04-20T08:00:00.000Z',
authorDisplayName: '造物者',
worldName: '潮影群岛',
subtitle: '港雾与旧航道',
summaryText: '一座在潮汐中漂移的群岛。',
coverImageSrc: '/covers/tide.png',
themeMode: 'tide',
playableNpcCount: 2,
landmarkCount: 3,
},
entries: [],
};
},
async deleteCustomWorldProfile() {
return [];
},
async listCustomWorldSessions() {
return [];
},
async getCustomWorldSession() {
return null;
},
async upsertCustomWorldSession(_userId, _sessionId, session) {
return session;
},
async publishCustomWorldProfile() {
return {
entry: {
ownerUserId: 'owner-1',
profileId: 'profile-1',
profile: {
id: 'profile-1',
},
visibility: 'published',
publishedAt: '2026-04-20T08:00:00.000Z',
updatedAt: '2026-04-20T08:00:00.000Z',
authorDisplayName: '造物者',
worldName: '潮影群岛',
subtitle: '港雾与旧航道',
summaryText: '一座在潮汐中漂移的群岛。',
coverImageSrc: '/covers/tide.png',
themeMode: 'tide',
playableNpcCount: 2,
landmarkCount: 3,
},
entries: [],
};
},
async unpublishCustomWorldProfile() {
return null;
},
async listPublishedCustomWorldGallery() {
return [
{
ownerUserId: 'owner-1',
profileId: 'profile-1',
visibility: 'published',
publishedAt: '2026-04-20T08:00:00.000Z',
updatedAt: '2026-04-20T08:00:00.000Z',
authorDisplayName: '造物者',
worldName: '潮影群岛',
subtitle: '港雾与旧航道',
summaryText: '一座在潮汐中漂移的群岛。',
coverImageSrc: '/covers/tide.png',
themeMode: 'tide',
playableNpcCount: 2,
landmarkCount: 3,
},
];
},
async getPublishedCustomWorldGalleryDetail() {
return {
ownerUserId: 'owner-1',
profileId: 'profile-1',
profile: {
id: 'profile-1',
},
visibility: 'published',
publishedAt: '2026-04-20T08:00:00.000Z',
updatedAt: '2026-04-20T08:00:00.000Z',
authorDisplayName: '造物者',
worldName: '潮影群岛',
subtitle: '港雾与旧航道',
summaryText: '一座在潮汐中漂移的群岛。',
coverImageSrc: '/covers/tide.png',
themeMode: 'tide',
playableNpcCount: 2,
landmarkCount: 3,
};
},
};
}
test('RpgSaveArchiveRepository 只承接继续游戏归档读取职责', async () => {
const repository = new RpgSaveArchiveRepository(createRuntimeRepositoryStub());
const archives = await repository.listProfileSaveArchives('user-1');
const resumed = await repository.resumeProfileSaveArchive('user-1', 'world-1');
assert.equal(archives[0]?.worldName, '潮影群岛');
assert.equal(resumed?.snapshot.bottomTab, 'adventure');
assert.equal('getSnapshot' in repository, false);
});
test('RpgWorldLibraryRepository 独立承接作品库与广场读取职责', async () => {
const repository = new RpgWorldLibraryRepository(createRuntimeRepositoryStub());
const profiles = await repository.listCustomWorldProfiles('user-1');
const gallery = await repository.listPublishedCustomWorldGallery();
const detail = await repository.getPublishedCustomWorldGalleryDetail(
'owner-1',
'profile-1',
);
assert.equal(profiles[0]?.worldName, '潮影群岛');
assert.equal(gallery[0]?.themeMode, 'tide');
assert.equal(detail?.profileId, 'profile-1');
assert.equal('listProfileSaveArchives' in repository, false);
});

View File

@@ -0,0 +1,36 @@
import type {
RuntimeRepositoryPort,
SavedSnapshot,
} from '../runtimeRepository.js';
import type { ProfileSaveArchiveSummary } from '../../../../packages/shared/src/contracts/runtime.js';
/**
* RPG 继续游戏归档仓储端口。
* 当前仍由 runtimeRepository 提供真实实现,本文件只建立按领域命名的兼容入口。
*/
export type RpgSaveArchiveRepositoryPort = Pick<
RuntimeRepositoryPort,
'listProfileSaveArchives' | 'resumeProfileSaveArchive'
>;
export type RpgSaveArchiveSnapshot = SavedSnapshot;
export class RpgSaveArchiveRepository implements RpgSaveArchiveRepositoryPort {
constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {}
listProfileSaveArchives(userId: string): Promise<ProfileSaveArchiveSummary[]> {
return this.runtimeRepository.listProfileSaveArchives(userId);
}
resumeProfileSaveArchive(
userId: string,
worldKey: string,
): Promise<
| {
entry: ProfileSaveArchiveSummary;
snapshot: RpgSaveArchiveSnapshot;
}
| null
> {
return this.runtimeRepository.resumeProfileSaveArchive(userId, worldKey);
}
}

View File

@@ -0,0 +1,92 @@
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
CustomWorldProfileRecord,
} from '../../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../runtimeRepository.js';
/**
* RPG 世界库仓储端口。
* 当前先桥接旧 runtimeRepository 中的世界库与广场读写,为工作包 H 的按域拆仓储提供命名骨架。
*/
export type RpgWorldLibraryRepositoryPort = Pick<
RuntimeRepositoryPort,
| 'deleteCustomWorldProfile'
| 'getPublishedCustomWorldGalleryDetail'
| 'listCustomWorldProfiles'
| 'listPublishedCustomWorldGallery'
| 'publishCustomWorldProfile'
| 'unpublishCustomWorldProfile'
| 'upsertCustomWorldProfile'
>;
export class RpgWorldLibraryRepository
implements RpgWorldLibraryRepositoryPort
{
constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {}
listCustomWorldProfiles(
userId: string,
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]> {
return this.runtimeRepository.listCustomWorldProfiles(userId);
}
upsertCustomWorldProfile(
userId: string,
profileId: string,
profile: Record<string, unknown>,
authorDisplayName: string,
) {
return this.runtimeRepository.upsertCustomWorldProfile(
userId,
profileId,
profile,
authorDisplayName,
);
}
deleteCustomWorldProfile(
userId: string,
profileId: string,
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]> {
return this.runtimeRepository.deleteCustomWorldProfile(userId, profileId);
}
publishCustomWorldProfile(
userId: string,
profileId: string,
authorDisplayName: string,
) {
return this.runtimeRepository.publishCustomWorldProfile(
userId,
profileId,
authorDisplayName,
);
}
unpublishCustomWorldProfile(
userId: string,
profileId: string,
authorDisplayName: string,
) {
return this.runtimeRepository.unpublishCustomWorldProfile(
userId,
profileId,
authorDisplayName,
);
}
listPublishedCustomWorldGallery(): Promise<CustomWorldGalleryCard[]> {
return this.runtimeRepository.listPublishedCustomWorldGallery();
}
getPublishedCustomWorldGalleryDetail(
ownerUserId: string,
profileId: string,
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord> | null> {
return this.runtimeRepository.getPublishedCustomWorldGalleryDetail(
ownerUserId,
profileId,
);
}
}

View File

@@ -0,0 +1,42 @@
import type {
PlatformBrowseHistoryEntry,
PlatformBrowseHistoryWriteEntry,
} from '../../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../runtimeRepository.js';
/**
* RPG 浏览历史仓储端口。
* 将继续游戏与平台浏览足迹独立命名,避免继续堆叠在资料看板仓储内。
*/
export type RpgBrowseHistoryRepositoryPort = Pick<
RuntimeRepositoryPort,
| 'clearPlatformBrowseHistory'
| 'listPlatformBrowseHistory'
| 'upsertPlatformBrowseHistoryEntries'
>;
export class RpgBrowseHistoryRepository
implements RpgBrowseHistoryRepositoryPort
{
constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {}
listPlatformBrowseHistory(
userId: string,
): Promise<PlatformBrowseHistoryEntry[]> {
return this.runtimeRepository.listPlatformBrowseHistory(userId);
}
upsertPlatformBrowseHistoryEntries(
userId: string,
entries: PlatformBrowseHistoryWriteEntry[],
): Promise<PlatformBrowseHistoryEntry[]> {
return this.runtimeRepository.upsertPlatformBrowseHistoryEntries(
userId,
entries,
);
}
clearPlatformBrowseHistory(userId: string): Promise<void> {
return this.runtimeRepository.clearPlatformBrowseHistory(userId);
}
}

View File

@@ -0,0 +1,49 @@
import type {
ProfileDashboardSummary,
ProfilePlayStatsResponse,
ProfileWalletLedgerEntry,
RuntimeSettings,
} from '../../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../runtimeRepository.js';
/**
* RPG profile 域仓储端口。
* 当前以委托方式桥接旧 runtimeRepository给后续按域仓储拆分保留稳定依赖面。
*/
export type RpgProfileDashboardRepositoryPort = Pick<
RuntimeRepositoryPort,
| 'getProfileDashboard'
| 'getProfilePlayStats'
| 'getSettings'
| 'listProfileWalletLedger'
| 'putSettings'
>;
export class RpgProfileDashboardRepository
implements RpgProfileDashboardRepositoryPort
{
constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {}
getProfileDashboard(userId: string): Promise<ProfileDashboardSummary> {
return this.runtimeRepository.getProfileDashboard(userId);
}
listProfileWalletLedger(userId: string): Promise<ProfileWalletLedgerEntry[]> {
return this.runtimeRepository.listProfileWalletLedger(userId);
}
getProfilePlayStats(userId: string): Promise<ProfilePlayStatsResponse> {
return this.runtimeRepository.getProfilePlayStats(userId);
}
getSettings(userId: string): Promise<RuntimeSettings> {
return this.runtimeRepository.getSettings(userId);
}
putSettings(
userId: string,
settings: RuntimeSettings,
): Promise<RuntimeSettings> {
return this.runtimeRepository.putSettings(userId, settings);
}
}

View File

@@ -0,0 +1,154 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { RuntimeRepositoryPort } from '../runtimeRepository.js';
import { RpgBrowseHistoryRepository } from './RpgBrowseHistoryRepository.js';
import { RpgProfileDashboardRepository } from './RpgProfileDashboardRepository.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
return {
async getSnapshot() {
return null;
},
async putSnapshot(_userId, payload) {
return {
version: 1,
...payload,
};
},
async getProfileDashboard() {
return {
walletBalance: 0,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-04-21T00:00:00.000Z',
};
},
async listProfileWalletLedger() {
return [];
},
async getProfilePlayStats() {
return {
totalPlayTimeMs: 0,
playedWorks: [],
updatedAt: '2026-04-21T00:00:00.000Z',
};
},
async listProfileSaveArchives() {
return [];
},
async resumeProfileSaveArchive() {
return null;
},
async deleteSnapshot() {},
async getSettings() {
return {
musicVolume: 0.5,
platformTheme: 'light',
};
},
async putSettings(_userId, settings) {
return settings;
},
async listCustomWorldProfiles() {
return [];
},
async listPlatformBrowseHistory() {
return [
{
ownerUserId: 'owner-1',
profileId: 'profile-1',
worldName: '雾港',
subtitle: '沿海试炼',
summaryText: '最近访问',
coverImageSrc: null,
themeMode: 'mythic',
authorDisplayName: '测试者',
visitedAt: '2026-04-21T00:00:00.000Z',
},
];
},
async upsertPlatformBrowseHistoryEntries(_userId, entries) {
return entries.map((entry) => ({
ownerUserId: entry.ownerUserId,
profileId: entry.profileId,
worldName: entry.worldName,
subtitle: entry.subtitle ?? '',
summaryText: entry.summaryText ?? '',
coverImageSrc: entry.coverImageSrc ?? null,
themeMode: entry.themeMode ?? 'mythic',
authorDisplayName: entry.authorDisplayName ?? '玩家',
visitedAt: entry.visitedAt ?? '2026-04-21T00:00:00.000Z',
}));
},
async clearPlatformBrowseHistory() {},
async upsertCustomWorldProfile() {
return {
entry: {} as never,
entries: [],
};
},
async deleteCustomWorldProfile() {
return [];
},
async listCustomWorldSessions() {
return [];
},
async getCustomWorldSession() {
return null;
},
async upsertCustomWorldSession(_userId, _sessionId, session) {
return session;
},
async publishCustomWorldProfile() {
return null;
},
async unpublishCustomWorldProfile() {
return null;
},
async listPublishedCustomWorldGallery() {
return [];
},
async getPublishedCustomWorldGalleryDetail() {
return null;
},
};
}
test('RpgProfileDashboardRepository 只暴露资料看板域方法', async () => {
const repository = new RpgProfileDashboardRepository(
createRuntimeRepositoryStub(),
);
const dashboard = await repository.getProfileDashboard('user-1');
const playStats = await repository.getProfilePlayStats('user-1');
const settings = await repository.getSettings('user-1');
assert.equal(dashboard.playedWorldCount, 0);
assert.equal(playStats.playedWorks.length, 0);
assert.equal(settings.platformTheme, 'light');
assert.equal('listPlatformBrowseHistory' in repository, false);
});
test('RpgBrowseHistoryRepository 独立承接浏览历史读写,不再混入资料看板仓储', async () => {
const repository = new RpgBrowseHistoryRepository(createRuntimeRepositoryStub());
const history = await repository.listPlatformBrowseHistory('user-1');
const updated = await repository.upsertPlatformBrowseHistoryEntries('user-1', [
{
ownerUserId: 'owner-2',
profileId: 'profile-2',
worldName: '盐雾镇',
subtitle: '盐路补给点',
summaryText: '测试写入浏览历史',
coverImageSrc: null,
themeMode: 'mythic',
authorDisplayName: '测试者二号',
visitedAt: '2026-04-21T01:00:00.000Z',
},
]);
assert.equal(history[0]?.worldName, '雾港');
assert.equal(updated[0]?.profileId, 'profile-2');
assert.equal('getProfileDashboard' in repository, false);
});

View File

@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { RuntimeRepositoryPort } from '../runtimeRepository.js';
import { RpgRuntimeSnapshotRepository } from './RpgRuntimeSnapshotRepository.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
const deletedUserIds: string[] = [];
return {
async getSnapshot(userId) {
return {
version: 2,
savedAt: '2026-04-21T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {
owner: userId,
},
currentStory: null,
};
},
async putSnapshot(_userId, payload) {
return {
version: 2,
...payload,
};
},
async getProfileDashboard() {
return {
walletBalance: 0,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-04-21T00:00:00.000Z',
};
},
async listProfileWalletLedger() {
return [];
},
async getProfilePlayStats() {
return {
totalPlayTimeMs: 0,
playedWorks: [],
updatedAt: '2026-04-21T00:00:00.000Z',
};
},
async listProfileSaveArchives() {
return [];
},
async resumeProfileSaveArchive() {
return null;
},
async deleteSnapshot(userId) {
deletedUserIds.push(userId);
},
async getSettings() {
return {
musicVolume: 0.42,
platformTheme: 'light',
};
},
async putSettings(_userId, settings) {
return settings;
},
async listCustomWorldProfiles() {
return [];
},
async listPlatformBrowseHistory() {
return [];
},
async upsertPlatformBrowseHistoryEntries() {
return [];
},
async clearPlatformBrowseHistory() {},
async upsertCustomWorldProfile() {
return {
entry: {} as never,
entries: [],
};
},
async deleteCustomWorldProfile() {
return [];
},
async listCustomWorldSessions() {
return [];
},
async getCustomWorldSession() {
return null;
},
async upsertCustomWorldSession(_userId, _sessionId, session) {
return session;
},
async publishCustomWorldProfile() {
return null;
},
async unpublishCustomWorldProfile() {
return null;
},
async listPublishedCustomWorldGallery() {
return [];
},
async getPublishedCustomWorldGalleryDetail() {
return null;
},
};
}
test('RpgRuntimeSnapshotRepository 独立承接 snapshot 读写与删除职责', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const repository = new RpgRuntimeSnapshotRepository(runtimeRepository);
const snapshot = await repository.getSnapshot('user-7');
const saved = await repository.putSnapshot('user-7', {
savedAt: '2026-04-21T01:00:00.000Z',
bottomTab: 'inventory',
gameState: {
owner: 'user-7',
currentScene: '雾港',
},
currentStory: null,
});
await repository.deleteSnapshot('user-7');
assert.equal(snapshot?.gameState.owner, 'user-7');
assert.equal(saved.bottomTab, 'inventory');
assert.equal('listProfileSaveArchives' in repository, false);
});

View File

@@ -0,0 +1,35 @@
import type {
RuntimeRepositoryPort,
SavedSnapshot,
} from '../runtimeRepository.js';
/**
* RPG runtime 快照仓储端口。
* 工作包 A 先把 snapshot 领域从大仓储中抽出独立命名入口,真实读写仍委托现有 runtimeRepository。
*/
export type RpgRuntimeSnapshotRepositoryPort = Pick<
RuntimeRepositoryPort,
'deleteSnapshot' | 'getSnapshot' | 'putSnapshot'
>;
export type RpgRuntimeSavedSnapshot = SavedSnapshot;
export class RpgRuntimeSnapshotRepository
implements RpgRuntimeSnapshotRepositoryPort
{
constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {}
getSnapshot(userId: string): Promise<RpgRuntimeSavedSnapshot | null> {
return this.runtimeRepository.getSnapshot(userId);
}
putSnapshot(
userId: string,
payload: Omit<RpgRuntimeSavedSnapshot, 'version'>,
): Promise<RpgRuntimeSavedSnapshot> {
return this.runtimeRepository.putSnapshot(userId, payload);
}
deleteSnapshot(userId: string): Promise<void> {
return this.runtimeRepository.deleteSnapshot(userId);
}
}

View File

@@ -0,0 +1,116 @@
import type { QueryResultRow } from 'pg';
import type {
CustomWorldProfileRecord,
} from '../../../packages/shared/src/contracts/runtime.js';
import {
type CustomWorldGalleryCard,
type CustomWorldLibraryEntry,
type CustomWorldPublicationStatus,
type CustomWorldSessionRecord,
} from '../../../packages/shared/src/contracts/runtime.js';
import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js';
export const MAX_RPG_WORLD_PROFILE_ENTRIES = 12;
export const MAX_RPG_WORLD_GALLERY_ENTRIES = 36;
export type RpgWorldProfileRow = QueryResultRow & {
ownerUserId: string;
profileId: string;
payload: CustomWorldProfileRecord;
visibility: CustomWorldPublicationStatus;
publishedAt: string | null;
updatedAt: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeMode: CustomWorldLibraryEntry['themeMode'];
playableNpcCount: number;
landmarkCount: number;
};
export type RpgAgentSessionRow = QueryResultRow & {
payload: CustomWorldSessionRecord;
createdAt: string;
updatedAt: string;
};
export type RpgWorldGalleryRow = QueryResultRow & {
ownerUserId: string;
profileId: string;
visibility: CustomWorldPublicationStatus;
publishedAt: string | null;
updatedAt: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeMode: CustomWorldGalleryCard['themeMode'];
playableNpcCount: number;
landmarkCount: number;
};
/**
* 落库前统一补齐 profileId避免不同入口写入时出现同一世界两个 id 口径。
*/
export function normalizeStoredRpgWorldProfile(
profileId: string,
profile: Record<string, unknown>,
): CustomWorldProfileRecord {
return {
...profile,
id: profileId,
};
}
export function toRpgWorldLibraryEntry(
row: RpgWorldProfileRow,
): CustomWorldLibraryEntry<CustomWorldProfileRecord> {
const fallbackMetadata = extractCustomWorldLibraryMetadata(row.payload);
return {
ownerUserId: row.ownerUserId,
profileId: row.profileId,
profile: row.payload,
visibility: row.visibility,
publishedAt: row.publishedAt,
updatedAt: row.updatedAt,
authorDisplayName: row.authorDisplayName || '玩家',
worldName: row.worldName || fallbackMetadata.worldName,
subtitle: row.subtitle || fallbackMetadata.subtitle,
summaryText: row.summaryText || fallbackMetadata.summaryText,
coverImageSrc: row.coverImageSrc || fallbackMetadata.coverImageSrc,
themeMode: row.themeMode || fallbackMetadata.themeMode,
playableNpcCount:
row.playableNpcCount > 0
? row.playableNpcCount
: fallbackMetadata.playableNpcCount,
landmarkCount:
row.landmarkCount > 0
? row.landmarkCount
: fallbackMetadata.landmarkCount,
};
}
export function toRpgWorldGalleryCard(
row: RpgWorldGalleryRow,
): CustomWorldGalleryCard {
return {
ownerUserId: row.ownerUserId,
profileId: row.profileId,
visibility: row.visibility,
publishedAt: row.publishedAt,
updatedAt: row.updatedAt,
authorDisplayName: row.authorDisplayName || '玩家',
worldName: row.worldName || '未命名世界',
subtitle: row.subtitle || '',
summaryText: row.summaryText || '',
coverImageSrc: row.coverImageSrc || null,
themeMode: row.themeMode || 'mythic',
playableNpcCount: row.playableNpcCount,
landmarkCount: row.landmarkCount,
};
}

View File

@@ -25,9 +25,9 @@ import {
} from '../../../packages/shared/src/contracts/runtime.js';
import type { AppDatabase } from '../db.js';
import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js';
const MAX_CUSTOM_WORLD_PROFILES = 12;
const MAX_PUBLIC_CUSTOM_WORLD_PROFILES = 36;
import { RpgAgentSessionRepository } from './RpgAgentSessionRepository.js';
import { RpgWorldProfileRepository } from './RpgWorldProfileRepository.js';
import { normalizeStoredRpgWorldProfile } from './rpgWorldRepositoryShared.js';
export type SavedSnapshot = SavedGameSnapshot<unknown, string, unknown>;
@@ -44,45 +44,6 @@ type SettingsRow = QueryResultRow & {
platformTheme: RuntimeSettings['platformTheme'];
};
type CustomWorldEntryRow = QueryResultRow & {
ownerUserId: string;
profileId: string;
payload: CustomWorldProfileRecord;
visibility: CustomWorldPublicationStatus;
publishedAt: string | null;
updatedAt: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeMode: CustomWorldLibraryEntry['themeMode'];
playableNpcCount: number;
landmarkCount: number;
};
type SessionRow = QueryResultRow & {
payload: CustomWorldSessionRecord;
createdAt: string;
updatedAt: string;
};
type CustomWorldCardRow = QueryResultRow & {
ownerUserId: string;
profileId: string;
visibility: CustomWorldPublicationStatus;
publishedAt: string | null;
updatedAt: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeMode: CustomWorldGalleryCard['themeMode'];
playableNpcCount: number;
landmarkCount: number;
};
type PlatformBrowseHistoryRow = QueryResultRow & {
ownerUserId: string;
profileId: string;
@@ -227,65 +188,6 @@ export type RuntimeRepositoryPort = {
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord> | null>;
};
function normalizeStoredProfile(
profileId: string,
profile: Record<string, unknown>,
): CustomWorldProfileRecord {
return {
...profile,
id: profileId,
};
}
function toCustomWorldLibraryEntry(
row: CustomWorldEntryRow,
): CustomWorldLibraryEntry<CustomWorldProfileRecord> {
const fallbackMetadata = extractCustomWorldLibraryMetadata(row.payload);
return {
ownerUserId: row.ownerUserId,
profileId: row.profileId,
profile: row.payload,
visibility: row.visibility,
publishedAt: row.publishedAt,
updatedAt: row.updatedAt,
authorDisplayName: row.authorDisplayName || '玩家',
worldName: row.worldName || fallbackMetadata.worldName,
subtitle: row.subtitle || fallbackMetadata.subtitle,
summaryText: row.summaryText || fallbackMetadata.summaryText,
coverImageSrc: row.coverImageSrc || fallbackMetadata.coverImageSrc,
themeMode: row.themeMode || fallbackMetadata.themeMode,
playableNpcCount:
row.playableNpcCount > 0
? row.playableNpcCount
: fallbackMetadata.playableNpcCount,
landmarkCount:
row.landmarkCount > 0
? row.landmarkCount
: fallbackMetadata.landmarkCount,
};
}
function toCustomWorldGalleryCard(
row: CustomWorldCardRow,
): CustomWorldGalleryCard {
return {
ownerUserId: row.ownerUserId,
profileId: row.profileId,
visibility: row.visibility,
publishedAt: row.publishedAt,
updatedAt: row.updatedAt,
authorDisplayName: row.authorDisplayName || '玩家',
worldName: row.worldName || '未命名世界',
subtitle: row.subtitle || '',
summaryText: row.summaryText || '',
coverImageSrc: row.coverImageSrc || null,
themeMode: row.themeMode || 'mythic',
playableNpcCount: row.playableNpcCount,
landmarkCount: row.landmarkCount,
};
}
function toPlatformBrowseHistoryEntry(
row: PlatformBrowseHistoryRow,
): PlatformBrowseHistoryEntry {
@@ -678,7 +580,7 @@ function resolveProfileSaveArchiveMeta(
if (customWorldProfile) {
const profileId = readString(customWorldProfile.id) || 'custom-world';
const metadata = extractCustomWorldLibraryMetadata(
normalizeStoredProfile(profileId, customWorldProfile),
normalizeStoredRpgWorldProfile(profileId, customWorldProfile),
);
return {
@@ -717,33 +619,12 @@ function resolveProfileSaveArchiveMeta(
}
export class RuntimeRepository implements RuntimeRepositoryPort {
constructor(private readonly db: AppDatabase) {}
private readonly rpgAgentSessionRepository: RpgAgentSessionRepository;
private readonly rpgWorldProfileRepository: RpgWorldProfileRepository;
private async findCustomWorldProfileEntry(userId: string, profileId: string) {
const result = await this.db.query<CustomWorldEntryRow>(
`SELECT user_id AS "ownerUserId",
profile_id AS "profileId",
payload_json AS payload,
visibility,
published_at AS "publishedAt",
updated_at AS "updatedAt",
author_display_name AS "authorDisplayName",
world_name AS "worldName",
subtitle,
summary_text AS "summaryText",
cover_image_src AS "coverImageSrc",
theme_mode AS "themeMode",
playable_npc_count AS "playableNpcCount",
landmark_count AS "landmarkCount"
FROM custom_world_profiles
WHERE user_id = $1
AND profile_id = $2
AND deleted_at IS NULL`,
[userId, profileId],
);
const row = result.rows[0];
return row ? toCustomWorldLibraryEntry(row) : null;
constructor(private readonly db: AppDatabase) {
this.rpgAgentSessionRepository = new RpgAgentSessionRepository(db);
this.rpgWorldProfileRepository = new RpgWorldProfileRepository(db);
}
private async getProfileDashboardState(userId: string) {
@@ -1043,52 +924,13 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
return;
}
const payload = normalizeStoredProfile(profileId, customWorldProfile);
const metadata = extractCustomWorldLibraryMetadata(payload);
const syncedAt = snapshot.savedAt || new Date().toISOString();
await this.db.query(
`INSERT INTO custom_world_profiles (
user_id,
profile_id,
payload_json,
updated_at,
author_display_name,
world_name,
subtitle,
summary_text,
cover_image_src,
theme_mode,
playable_npc_count,
landmark_count,
deleted_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULL)
ON CONFLICT (user_id, profile_id) DO UPDATE SET
payload_json = EXCLUDED.payload_json,
updated_at = EXCLUDED.updated_at,
deleted_at = NULL,
world_name = EXCLUDED.world_name,
subtitle = EXCLUDED.subtitle,
summary_text = EXCLUDED.summary_text,
cover_image_src = EXCLUDED.cover_image_src,
theme_mode = EXCLUDED.theme_mode,
playable_npc_count = EXCLUDED.playable_npc_count,
landmark_count = EXCLUDED.landmark_count`,
[
userId,
profileId,
payload,
syncedAt,
'玩家',
metadata.worldName,
metadata.subtitle,
metadata.summaryText,
metadata.coverImageSrc,
metadata.themeMode,
metadata.playableNpcCount,
metadata.landmarkCount,
],
await this.rpgWorldProfileRepository.syncProfileFromSnapshot(
userId,
profileId,
customWorldProfile,
syncedAt,
);
}
@@ -1394,29 +1236,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
}
async listCustomWorldProfiles(userId: string) {
const result = await this.db.query<CustomWorldEntryRow>(
`SELECT user_id AS "ownerUserId",
profile_id AS "profileId",
payload_json AS payload,
visibility,
published_at AS "publishedAt",
updated_at AS "updatedAt",
author_display_name AS "authorDisplayName",
world_name AS "worldName",
subtitle,
summary_text AS "summaryText",
cover_image_src AS "coverImageSrc",
theme_mode AS "themeMode",
playable_npc_count AS "playableNpcCount",
landmark_count AS "landmarkCount"
FROM custom_world_profiles
WHERE user_id = $1
AND deleted_at IS NULL
ORDER BY updated_at DESC
LIMIT $2`,
[userId, MAX_CUSTOM_WORLD_PROFILES],
);
return result.rows.map((row) => toCustomWorldLibraryEntry(row));
return this.rpgWorldProfileRepository.listOwnProfiles(userId);
}
async upsertCustomWorldProfile(
@@ -1425,120 +1245,27 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
profile: Record<string, unknown>,
authorDisplayName: string,
) {
const payload = normalizeStoredProfile(profileId, profile);
const metadata = extractCustomWorldLibraryMetadata(payload);
const now = new Date().toISOString();
await this.db.query(
`INSERT INTO custom_world_profiles (
user_id,
profile_id,
payload_json,
updated_at,
author_display_name,
world_name,
subtitle,
summary_text,
cover_image_src,
theme_mode,
playable_npc_count,
landmark_count
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (user_id, profile_id) DO UPDATE SET
payload_json = EXCLUDED.payload_json,
updated_at = EXCLUDED.updated_at,
deleted_at = NULL,
author_display_name = EXCLUDED.author_display_name,
world_name = EXCLUDED.world_name,
subtitle = EXCLUDED.subtitle,
summary_text = EXCLUDED.summary_text,
cover_image_src = EXCLUDED.cover_image_src,
theme_mode = EXCLUDED.theme_mode,
playable_npc_count = EXCLUDED.playable_npc_count,
landmark_count = EXCLUDED.landmark_count`,
[
userId,
profileId,
payload,
now,
authorDisplayName || '玩家',
metadata.worldName,
metadata.subtitle,
metadata.summaryText,
metadata.coverImageSrc,
metadata.themeMode,
metadata.playableNpcCount,
metadata.landmarkCount,
],
return this.rpgWorldProfileRepository.upsertOwnProfile(
userId,
profileId,
profile,
authorDisplayName,
);
const entry = await this.findCustomWorldProfileEntry(userId, profileId);
if (!entry) {
throw new Error('failed to resolve custom world after upsert');
}
return {
entry,
entries: await this.listCustomWorldProfiles(userId),
};
}
async deleteCustomWorldProfile(userId: string, profileId: string) {
const deletedAt = new Date().toISOString();
await this.db.query(
`UPDATE custom_world_profiles
SET deleted_at = $1,
updated_at = $1,
visibility = 'draft',
published_at = NULL
WHERE user_id = $2
AND profile_id = $3
AND deleted_at IS NULL`,
[deletedAt, userId, profileId],
return this.rpgWorldProfileRepository.softDeleteOwnProfile(
userId,
profileId,
);
return this.listCustomWorldProfiles(userId);
}
async listCustomWorldSessions(userId: string) {
const result = await this.db.query<SessionRow>(
`SELECT payload_json AS payload,
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM custom_world_sessions
WHERE user_id = $1
ORDER BY updated_at DESC`,
[userId],
);
return result.rows.map((row) => ({
...row.payload,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}));
return this.rpgAgentSessionRepository.listSessions(userId);
}
async getCustomWorldSession(userId: string, sessionId: string) {
const result = await this.db.query<SessionRow>(
`SELECT payload_json AS payload,
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM custom_world_sessions
WHERE user_id = $1 AND session_id = $2`,
[userId, sessionId],
);
const row = result.rows[0];
if (!row) {
return null;
}
return {
...row.payload,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
return this.rpgAgentSessionRepository.getSession(userId, sessionId);
}
async upsertCustomWorldSession(
@@ -1546,30 +1273,11 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
sessionId: string,
session: CustomWorldSessionRecord,
) {
const payload = {
...session,
return this.rpgAgentSessionRepository.upsertSession(
userId,
sessionId,
} satisfies CustomWorldSessionRecord;
await this.db.query(
`INSERT INTO custom_world_sessions (
user_id,
session_id,
payload_json,
created_at,
updated_at
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, session_id) DO UPDATE SET
payload_json = EXCLUDED.payload_json,
updated_at = EXCLUDED.updated_at`,
[userId, sessionId, payload, session.createdAt, session.updatedAt],
session,
);
return {
...payload,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
};
}
async publishCustomWorldProfile(
@@ -1577,57 +1285,11 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
profileId: string,
authorDisplayName: string,
) {
const existingEntry = await this.findCustomWorldProfileEntry(
return this.rpgWorldProfileRepository.publishOwnProfile(
userId,
profileId,
authorDisplayName,
);
if (!existingEntry) {
return null;
}
const payload = normalizeStoredProfile(profileId, existingEntry.profile);
const metadata = extractCustomWorldLibraryMetadata(payload);
const now = new Date().toISOString();
await this.db.query(
`UPDATE custom_world_profiles
SET visibility = 'published',
published_at = $1,
updated_at = $1,
author_display_name = $2,
world_name = $3,
subtitle = $4,
summary_text = $5,
cover_image_src = $6,
theme_mode = $7,
playable_npc_count = $8,
landmark_count = $9
WHERE user_id = $10
AND profile_id = $11`,
[
now,
authorDisplayName || '玩家',
metadata.worldName,
metadata.subtitle,
metadata.summaryText,
metadata.coverImageSrc,
metadata.themeMode,
metadata.playableNpcCount,
metadata.landmarkCount,
userId,
profileId,
],
);
const entry = await this.findCustomWorldProfileEntry(userId, profileId);
if (!entry) {
throw new Error('failed to resolve custom world after publish');
}
return {
entry,
entries: await this.listCustomWorldProfiles(userId),
};
}
async unpublishCustomWorldProfile(
@@ -1635,113 +1297,24 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
profileId: string,
authorDisplayName: string,
) {
const existingEntry = await this.findCustomWorldProfileEntry(
return this.rpgWorldProfileRepository.unpublishOwnProfile(
userId,
profileId,
authorDisplayName,
);
if (!existingEntry) {
return null;
}
const payload = normalizeStoredProfile(profileId, existingEntry.profile);
const metadata = extractCustomWorldLibraryMetadata(payload);
const now = new Date().toISOString();
await this.db.query(
`UPDATE custom_world_profiles
SET visibility = 'draft',
published_at = NULL,
updated_at = $1,
author_display_name = $2,
world_name = $3,
subtitle = $4,
summary_text = $5,
cover_image_src = $6,
theme_mode = $7,
playable_npc_count = $8,
landmark_count = $9
WHERE user_id = $10
AND profile_id = $11`,
[
now,
authorDisplayName || '玩家',
metadata.worldName,
metadata.subtitle,
metadata.summaryText,
metadata.coverImageSrc,
metadata.themeMode,
metadata.playableNpcCount,
metadata.landmarkCount,
userId,
profileId,
],
);
const entry = await this.findCustomWorldProfileEntry(userId, profileId);
if (!entry) {
throw new Error('failed to resolve custom world after unpublish');
}
return {
entry,
entries: await this.listCustomWorldProfiles(userId),
};
}
async listPublishedCustomWorldGallery() {
const result = await this.db.query<CustomWorldCardRow>(
`SELECT user_id AS "ownerUserId",
profile_id AS "profileId",
visibility,
published_at AS "publishedAt",
updated_at AS "updatedAt",
author_display_name AS "authorDisplayName",
world_name AS "worldName",
subtitle,
summary_text AS "summaryText",
cover_image_src AS "coverImageSrc",
theme_mode AS "themeMode",
playable_npc_count AS "playableNpcCount",
landmark_count AS "landmarkCount"
FROM custom_world_profiles
WHERE visibility = 'published'
AND deleted_at IS NULL
ORDER BY published_at DESC, updated_at DESC
LIMIT $1`,
[MAX_PUBLIC_CUSTOM_WORLD_PROFILES],
);
return result.rows.map((row) => toCustomWorldGalleryCard(row));
return this.rpgWorldProfileRepository.listPublishedGallery();
}
async getPublishedCustomWorldGalleryDetail(
ownerUserId: string,
profileId: string,
) {
const result = await this.db.query<CustomWorldEntryRow>(
`SELECT user_id AS "ownerUserId",
profile_id AS "profileId",
payload_json AS payload,
visibility,
published_at AS "publishedAt",
updated_at AS "updatedAt",
author_display_name AS "authorDisplayName",
world_name AS "worldName",
subtitle,
summary_text AS "summaryText",
cover_image_src AS "coverImageSrc",
theme_mode AS "themeMode",
playable_npc_count AS "playableNpcCount",
landmark_count AS "landmarkCount"
FROM custom_world_profiles
WHERE user_id = $1
AND profile_id = $2
AND visibility = 'published'
AND deleted_at IS NULL`,
[ownerUserId, profileId],
return this.rpgWorldProfileRepository.getPublishedGalleryDetail(
ownerUserId,
profileId,
);
const row = result.rows[0];
return row ? toCustomWorldLibraryEntry(row) : null;
}
}

View File

@@ -1,4 +1,4 @@
import { type Request, Router } from 'express';
import { type Request, type Response, Router } from 'express';
import { z } from 'zod';
import type {
@@ -376,6 +376,7 @@ export function createAuthRoutes(context: AppContext) {
buildRefreshCookieLifetimeSeconds(context, result.refreshExpiresAt),
);
sendApiResponse(response, {
ok: true,
token: result.token,
});
} catch (error) {

View File

@@ -9,6 +9,7 @@ import type {
import type { AppContext } from '../context.js';
import { badRequest, notFound } from '../errors.js';
import { asyncHandler, prepareApiResponse, sendApiResponse } from '../http.js';
import { requireJwtAuth } from '../middleware/auth.js';
import { routeMeta } from '../middleware/routeMeta.js';
const createSessionSchema = z.object({
@@ -39,6 +40,10 @@ const actionSchema = z.discriminatedUnion('action', [
)
.min(1),
}),
z.object({
action: z.literal('sync_result_profile'),
profile: z.record(z.string(), z.unknown()),
}),
z.object({
action: z.literal('generate_characters'),
count: z.number().int().min(1).max(3),
@@ -63,9 +68,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) {
@@ -74,6 +99,9 @@ function readParam(param: string | string[] | undefined) {
export function createCustomWorldAgentRoutes(context: AppContext) {
const router = Router();
const requireAuth = requireJwtAuth(context.config, context.userRepository);
router.use(requireAuth);
router.post(
'/sessions',

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,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,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;
}

View File

@@ -8,10 +8,10 @@ import test from 'node:test';
import { createApp } from '../../app.ts';
import { buildQuestForEncounter } from '../../bridges/legacyQuestProgressBridge.js';
import type { AppConfig } from '../../config.ts';
import { applyQuestSignal } from '../../modules/quest/questProgressionService.ts';
import { createAppContext } from '../../server.ts';
import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.ts';
import { httpRequest, type TestRequestInit } from '../../testHttp.ts';
import { applyQuestSignal } from '../quest/questProgressionService.ts';
function createTestConfig(testName: string): AppConfig {
const tempRoot = fs.mkdtempSync(
@@ -91,6 +91,11 @@ function createTestConfig(testName: string): AppConfig {
mockAvatarUrl: '',
},
authSession: {
accessCookieName: 'genarrative_access_session',
accessCookieTtlSeconds: 7200,
accessCookieSecure: false,
accessCookieSameSite: 'Lax',
accessCookiePath: '/',
refreshCookieName: 'genarrative_refresh_session',
refreshSessionTtlDays: 30,
refreshCookieSecure: false,
@@ -1881,6 +1886,243 @@ test('runtime story actions resolve equipment_equip and persist updated loadout'
});
});
test('runtime story actions resolve npc_recruit directly on the server', async () => {
await withTestServer('task6-recruit-direct', async ({ baseUrl }) => {
const entry = await authEntry(baseUrl, 'story_task6_recruit', 'secret123');
await putSnapshot(
baseUrl,
entry.token,
createTask6GameState({
currentEncounter: {
kind: 'npc',
id: 'npc_guard_01',
npcName: '守桥人',
npcDescription: '在桥口驻守多年的旧识',
context: '桥口守卫',
characterId: 'bridge-guard',
},
npcInteractionActive: true,
npcStates: {
npc_guard_01: {
affinity: 64,
chattedCount: 2,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
companions: [],
roster: [],
}),
);
const response = 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_recruit',
payload: {
preludeText:
'守桥人:你既然想清楚了,那我就跟你走这一程。',
},
},
}),
}),
);
const payload = (await response.json()) as {
presentation: {
storyText: string;
};
snapshot: {
gameState: {
currentEncounter: unknown;
npcInteractionActive: boolean;
companions: Array<{
npcId: string;
characterId: string;
joinedAtAffinity: number;
maxHp: number;
maxMana: number;
}>;
roster: Array<unknown>;
npcStates: {
npc_guard_01: {
recruited: boolean;
firstMeaningfulContactResolved: boolean;
};
};
};
};
viewModel: {
companions: Array<{
npcId: string;
}>;
};
};
assert.equal(response.status, 200);
assert.match(payload.presentation.storyText, //u);
assert.equal(payload.snapshot.gameState.currentEncounter, null);
assert.equal(payload.snapshot.gameState.npcInteractionActive, false);
assert.equal(payload.snapshot.gameState.companions.length, 1);
assert.equal(payload.snapshot.gameState.companions[0]?.npcId, 'npc_guard_01');
assert.equal(
payload.snapshot.gameState.companions[0]?.joinedAtAffinity,
64,
);
assert.ok((payload.snapshot.gameState.companions[0]?.maxHp ?? 0) > 0);
assert.ok((payload.snapshot.gameState.companions[0]?.maxMana ?? 0) > 0);
assert.deepEqual(payload.snapshot.gameState.roster, []);
assert.equal(
payload.snapshot.gameState.npcStates.npc_guard_01.recruited,
true,
);
assert.equal(
payload.snapshot.gameState.npcStates.npc_guard_01
.firstMeaningfulContactResolved,
true,
);
assert.equal(payload.viewModel.companions[0]?.npcId, 'npc_guard_01');
});
});
test('runtime story actions resolve npc_recruit with full-party replacement on the server', async () => {
await withTestServer('task6-recruit-swap', async ({ baseUrl }) => {
const entry = await authEntry(
baseUrl,
'story_task6_recruit_swap',
'secret123',
);
await putSnapshot(
baseUrl,
entry.token,
createTask6GameState({
currentEncounter: {
kind: 'npc',
id: 'npc_scout_02',
npcName: '追迹人',
npcDescription: '擅长沿痕追人的同路者',
context: '山道追迹',
characterId: 'trail-scout',
},
npcInteractionActive: true,
npcStates: {
npc_scout_02: {
affinity: 71,
chattedCount: 3,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
},
npc_old_guard: {
affinity: 48,
chattedCount: 1,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: true,
},
npc_old_medic: {
affinity: 55,
chattedCount: 1,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: true,
},
},
companions: [
{
npcId: 'npc_old_guard',
characterId: 'old-guard',
joinedAtAffinity: 48,
hp: 180,
maxHp: 180,
mana: 999,
maxMana: 999,
skillCooldowns: {},
animationState: 'idle',
actionMode: 'idle',
offsetX: 0,
offsetY: 0,
transitionMs: 0,
},
{
npcId: 'npc_old_medic',
characterId: 'old-medic',
joinedAtAffinity: 55,
hp: 170,
maxHp: 170,
mana: 999,
maxMana: 999,
skillCooldowns: {},
animationState: 'idle',
actionMode: 'idle',
offsetX: 0,
offsetY: 0,
transitionMs: 0,
},
],
roster: [],
}),
);
const response = 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_recruit',
payload: {
releaseNpcId: 'npc_old_guard',
preludeText:
'追迹人:如果你真要带我同行,那就先把你队里的位置理顺。',
},
},
}),
}),
);
const payload = (await response.json()) as {
snapshot: {
gameState: {
companions: Array<{ npcId: string }>;
roster: Array<{ npcId: string }>;
};
};
viewModel: {
companions: Array<{ npcId: string }>;
};
};
assert.equal(response.status, 200);
assert.deepEqual(
payload.snapshot.gameState.companions.map((companion) => companion.npcId),
['npc_scout_02', 'npc_old_medic'],
);
assert.deepEqual(
payload.snapshot.gameState.roster.map((companion) => companion.npcId),
['npc_old_guard'],
);
assert.deepEqual(
payload.viewModel.companions.map((companion) => companion.npcId),
['npc_scout_02', 'npc_old_medic'],
);
});
});
test('runtime story actions resolve npc_trade buy transactions on the server', async () => {
await withTestServer('task6-trade-buy', async ({ baseUrl }) => {
const entry = await authEntry(

View File

@@ -1,22 +1,24 @@
import { Router } from 'express';
import { z } from 'zod';
import type { RuntimeStoryActionRequest } from '../../../../packages/shared/src/contracts/story.js';
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,
resolveRuntimeStoryAction,
} from './storyActionService.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),
@@ -25,7 +27,15 @@ const runtimeStoryActionSchema = z.object({
}),
});
export function createStoryActionRoutes(context: AppContext) {
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);
@@ -41,7 +51,7 @@ export function createStoryActionRoutes(context: AppContext) {
sendApiResponse(
response,
await resolveRuntimeStoryAction({
runtimeRepository: context.runtimeRepository,
snapshotRepository: context.rpgRuntimeSnapshotRepository,
llmClient: context.llmClient,
userId: request.userId!,
request: payload,
@@ -62,7 +72,7 @@ export function createStoryActionRoutes(context: AppContext) {
sendApiResponse(
response,
await getRuntimeStoryState({
runtimeRepository: context.runtimeRepository,
snapshotRepository: context.rpgRuntimeSnapshotRepository,
userId: request.userId!,
sessionId,
}),
@@ -70,5 +80,25 @@ export function createStoryActionRoutes(context: AppContext) {
}),
);
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,956 +0,0 @@
import { Router } from 'express';
import { z } from 'zod';
import type { ListCustomWorldWorksResponse } from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type {
AnswerCustomWorldSessionQuestionRequest,
CreateCustomWorldSessionRequest,
CustomWorldGalleryDetailResponse,
CustomWorldGalleryResponse,
CustomWorldLibraryMutationResponse,
CustomWorldLibraryResponse,
PlatformBrowseHistoryBatchSyncRequest,
PlatformBrowseHistoryResponse,
PlatformBrowseHistoryWriteEntry,
ProfileDashboardSummary,
ProfilePlayStatsResponse,
ProfileSaveArchiveListResponse,
ProfileSaveArchiveResumeResponse,
ProfileWalletLedgerResponse,
RuntimeSettings,
SavedGameSnapshotInput,
} from '../../../packages/shared/src/contracts/runtime.js';
import {
CUSTOM_WORLD_GENERATION_MODES,
PLATFORM_THEMES,
} from '../../../packages/shared/src/contracts/runtime.js';
import type {
QuestGenerationRequest,
RuntimeItemIntentRequest,
} from '../../../packages/shared/src/contracts/story.js';
import type {
CharacterChatReplyRequest,
CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
NpcChatTurnRequest,
NpcRecruitDialogueRequest,
} from '../../../packages/shared/src/contracts/story.js';
import type { AppContext } from '../context.js';
import { badRequest, notFound } from '../errors.js';
import {
asyncHandler,
jsonClone,
prepareEventStreamResponse,
sendApiResponse,
} from '../http.js';
import { requireJwtAuth } from '../middleware/auth.js';
import { routeMeta } from '../middleware/routeMeta.js';
import {
generateCharacterChatSuggestionsFromOrchestrator,
generateCharacterChatSummaryFromOrchestrator,
streamCharacterChatReplyFromOrchestrator,
streamNpcChatDialogueFromOrchestrator,
streamNpcChatTurnFromOrchestrator,
streamNpcRecruitDialogueFromOrchestrator,
} from '../modules/ai/chatOrchestrator.js';
import {
hydrateSavedSnapshot,
normalizeSavedSnapshotPayload,
} from '../modules/runtime/runtimeSnapshotHydration.js';
import {
characterChatReplyRequestSchema,
characterChatSuggestionsRequestSchema,
characterChatSummaryRequestSchema,
npcChatDialogueRequestSchema,
npcChatTurnRequestSchema,
npcRecruitDialogueRequestSchema,
} from '../services/chatService.js';
import { generateCustomWorldEntity } from '../services/customWorldEntityGenerationService.js';
import { generateCustomWorldProfile } from '../services/customWorldGenerationService.js';
import { generateSceneNpcForLandmark } from '../services/customWorldSceneNpcGenerationService.js';
import { listCustomWorldWorkSummaries } from '../services/customWorldWorkSummaryService.js';
import { generateQuestForNpcEncounter } from '../services/questService.js';
import { generateRuntimeItemIntents } from '../services/runtimeItemService.js';
import {
customWorldCoverImageSchema,
customWorldCoverUploadSchema,
generateCustomWorldCoverImage,
uploadCustomWorldCoverImage,
} from '../services/customWorldCoverAssetService.js';
import {
generateSceneImage,
sceneImageSchema,
} from '../services/sceneImageService.js';
import {
generateHighQualityInitialStory,
generateHighQualityNextStory,
parseStoryRequest,
} from '../services/storyService.js';
import { createCustomWorldAgentRoutes } from './customWorldAgent.js';
const jsonObjectSchema = z.record(z.string(), z.unknown());
const saveSnapshotSchema = z.object({
gameState: z.unknown(),
bottomTab: z.string().trim().min(1),
currentStory: z.unknown().nullable().optional().default(null),
savedAt: z.string().trim().optional().default(''),
});
const settingsSchema = z.object({
musicVolume: z.number().min(0).max(1),
platformTheme: z.enum(PLATFORM_THEMES),
});
const platformBrowseHistoryEntrySchema = z.object({
ownerUserId: z.string().trim().min(1),
profileId: z.string().trim().min(1),
worldName: z.string().trim().min(1),
subtitle: z.string().trim().optional().default(''),
summaryText: z.string().trim().optional().default(''),
coverImageSrc: z.string().trim().nullable().optional().default(null),
themeMode: z.string().trim().optional().default('mythic'),
authorDisplayName: z.string().trim().optional().default('玩家'),
visitedAt: z.string().trim().optional().default(''),
});
const platformBrowseHistoryBatchSchema = z.object({
entries: z.array(platformBrowseHistoryEntrySchema).max(100),
});
const customWorldProfileSchema = z.object({
profile: jsonObjectSchema,
});
const customWorldSceneNpcSchema = z.object({
profile: jsonObjectSchema,
landmarkId: z.string().trim().min(1),
});
const customWorldEntitySchema = z.object({
profile: jsonObjectSchema,
kind: z.enum(['playable', 'story', 'landmark']),
});
const customWorldSessionSchema = z.object({
settingText: z.string().trim().min(1),
creatorIntent: jsonObjectSchema.nullable().optional().default(null),
generationMode: z.enum(CUSTOM_WORLD_GENERATION_MODES).default('fast'),
});
const customWorldAnswerSchema = z.object({
questionId: z.string().trim().min(1),
answer: z.string().trim().min(1),
});
const runtimeItemIntentSchema = z.object({
context: jsonObjectSchema,
plans: z.array(jsonObjectSchema),
});
const questGenerationSchema = z.object({
state: jsonObjectSchema,
encounter: jsonObjectSchema,
});
const llmProxySchema = jsonObjectSchema;
function readParam(param: string | string[] | undefined) {
return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || '';
}
async function resolveAuthDisplayName(context: AppContext, userId: string) {
const user = await context.userRepository.findById(userId);
if (!user) {
throw notFound('user not found');
}
return user.displayName?.trim() || '玩家';
}
export function createRuntimeRoutes(context: AppContext) {
const router = Router();
const requireAuth = requireJwtAuth(context.config, context.userRepository);
const routeCompatPaths = (path: string) => [
path,
`/runtime${path}`,
] as const;
const handleCustomWorldEntityGeneration = asyncHandler(async (request, response) => {
const payload = customWorldEntitySchema.parse(request.body) as {
profile: Record<string, unknown>;
kind: 'playable' | 'story' | 'landmark';
};
sendApiResponse(
response,
await generateCustomWorldEntity(context.llmClient, payload),
);
});
const handleCustomWorldSceneNpcGeneration = asyncHandler(async (request, response) => {
const payload = customWorldSceneNpcSchema.parse(request.body) as {
profile: Record<string, unknown>;
landmarkId: string;
};
sendApiResponse(response, {
npc: await generateSceneNpcForLandmark(context.llmClient, payload),
});
});
router.get(
'/runtime/custom-world-gallery',
routeMeta({ operation: 'runtime.customWorldGallery.list' }),
asyncHandler(async (_request, response) => {
sendApiResponse(response, {
entries: await context.runtimeRepository.listPublishedCustomWorldGallery(),
} satisfies CustomWorldGalleryResponse);
}),
);
router.get(
'/runtime/custom-world-gallery/:ownerUserId/:profileId',
routeMeta({ operation: 'runtime.customWorldGallery.detail' }),
asyncHandler(async (request, response) => {
const ownerUserId = readParam(request.params.ownerUserId);
const profileId = readParam(request.params.profileId);
if (!ownerUserId || !profileId) {
throw badRequest('ownerUserId and profileId are required');
}
const entry =
await context.runtimeRepository.getPublishedCustomWorldGalleryDetail(
ownerUserId,
profileId,
);
if (!entry) {
throw notFound('public custom world not found');
}
sendApiResponse(response, {
entry,
} satisfies CustomWorldGalleryDetailResponse);
}),
);
router.use(requireAuth);
router.use(
'/runtime/custom-world/agent',
createCustomWorldAgentRoutes(context),
);
routeCompatPaths('/profile/dashboard').forEach((path, index) => {
router.get(
path,
routeMeta({
operation:
index === 0
? 'profile.dashboard.get'
: 'profile.dashboard.get.compat',
}),
asyncHandler(async (request, response) => {
sendApiResponse<ProfileDashboardSummary>(
response,
await context.runtimeRepository.getProfileDashboard(request.userId!),
);
}),
);
});
routeCompatPaths('/profile/wallet-ledger').forEach((path, index) => {
router.get(
path,
routeMeta({
operation:
index === 0
? 'profile.walletLedger.list'
: 'profile.walletLedger.list.compat',
}),
asyncHandler(async (request, response) => {
sendApiResponse<ProfileWalletLedgerResponse>(response, {
entries: await context.runtimeRepository.listProfileWalletLedger(
request.userId!,
),
});
}),
);
});
routeCompatPaths('/profile/play-stats').forEach((path, index) => {
router.get(
path,
routeMeta({
operation:
index === 0
? 'profile.playStats.get'
: 'profile.playStats.get.compat',
}),
asyncHandler(async (request, response) => {
sendApiResponse<ProfilePlayStatsResponse>(
response,
await context.runtimeRepository.getProfilePlayStats(request.userId!),
);
}),
);
});
routeCompatPaths('/profile/browse-history').forEach((path, index) => {
router.get(
path,
routeMeta({
operation:
index === 0
? 'profile.browseHistory.list'
: 'profile.browseHistory.list.compat',
}),
asyncHandler(async (request, response) => {
sendApiResponse<PlatformBrowseHistoryResponse>(response, {
entries: await context.runtimeRepository.listPlatformBrowseHistory(
request.userId!,
),
});
}),
);
router.post(
path,
routeMeta({
operation:
index === 0
? 'profile.browseHistory.upsert'
: 'profile.browseHistory.upsert.compat',
}),
asyncHandler(async (request, response) => {
const rawBody =
request.body && typeof request.body === 'object' ? request.body : {};
const payload = (
'entries' in rawBody
? platformBrowseHistoryBatchSchema.parse(rawBody)
: platformBrowseHistoryEntrySchema.parse(rawBody)
) as
| PlatformBrowseHistoryBatchSyncRequest
| PlatformBrowseHistoryWriteEntry;
const entries = 'entries' in payload ? payload.entries : [payload];
sendApiResponse<PlatformBrowseHistoryResponse>(response, {
entries:
await context.runtimeRepository.upsertPlatformBrowseHistoryEntries(
request.userId!,
entries,
),
});
}),
);
router.delete(
path,
routeMeta({
operation:
index === 0
? 'profile.browseHistory.clear'
: 'profile.browseHistory.clear.compat',
}),
asyncHandler(async (request, response) => {
await context.runtimeRepository.clearPlatformBrowseHistory(
request.userId!,
);
sendApiResponse<PlatformBrowseHistoryResponse>(response, {
entries: [],
});
}),
);
});
routeCompatPaths('/profile/save-archives').forEach((path, index) => {
router.get(
path,
routeMeta({
operation:
index === 0
? 'profile.saveArchives.list'
: 'profile.saveArchives.list.compat',
}),
asyncHandler(async (request, response) => {
sendApiResponse<ProfileSaveArchiveListResponse>(response, {
entries: await context.runtimeRepository.listProfileSaveArchives(
request.userId!,
),
});
}),
);
});
[
'/profile/save-archives/:worldKey',
'/runtime/profile/save-archives/:worldKey',
].forEach((path, index) => {
router.post(
path,
routeMeta({
operation:
index === 0
? 'profile.saveArchives.resume'
: 'profile.saveArchives.resume.compat',
}),
asyncHandler(async (request, response) => {
const worldKey =
typeof request.params.worldKey === 'string'
? request.params.worldKey.trim()
: '';
if (!worldKey) {
throw badRequest('worldKey 不能为空');
}
const resumedArchive =
await context.runtimeRepository.resumeProfileSaveArchive(
request.userId!,
worldKey,
);
if (!resumedArchive) {
throw notFound('指定存档不存在');
}
sendApiResponse<ProfileSaveArchiveResumeResponse>(response, {
entry: resumedArchive.entry,
snapshot: hydrateSavedSnapshot(resumedArchive.snapshot)!,
});
}),
);
});
router.post(
'/llm/chat/completions',
routeMeta({ operation: 'runtime.llm.chatCompletionsProxy' }),
asyncHandler(async (request, response) => {
const body = llmProxySchema.parse(request.body);
await context.llmClient.forwardCompletion(request, body, response);
}),
);
router.post(
'/custom-world/cover-image',
routeMeta({ operation: 'runtime.customWorld.coverImage' }),
asyncHandler(async (request, response) => {
const payload = customWorldCoverImageSchema.parse(request.body);
sendApiResponse(response, await generateCustomWorldCoverImage(context, payload));
}),
);
router.post(
'/custom-world/cover-upload',
routeMeta({ operation: 'runtime.customWorld.coverUpload' }),
asyncHandler(async (request, response) => {
const payload = customWorldCoverUploadSchema.parse(request.body);
sendApiResponse(response, await uploadCustomWorldCoverImage(context, payload));
}),
);
router.post(
'/custom-world/scene-image',
routeMeta({ operation: 'runtime.customWorld.sceneImage' }),
asyncHandler(async (request, response) => {
const payload = sceneImageSchema.parse(request.body);
sendApiResponse(response, await generateSceneImage(context, payload));
}),
);
router.post(
'/custom-world/entity',
routeMeta({ operation: 'runtime.customWorld.entity' }),
handleCustomWorldEntityGeneration,
);
router.post(
'/runtime/custom-world/entity',
routeMeta({ operation: 'runtime.customWorld.entity.compat' }),
handleCustomWorldEntityGeneration,
);
router.post(
'/custom-world/scene-npc',
routeMeta({ operation: 'runtime.customWorld.sceneNpc' }),
handleCustomWorldSceneNpcGeneration,
);
router.post(
'/runtime/custom-world/scene-npc',
routeMeta({ operation: 'runtime.customWorld.sceneNpc.compat' }),
handleCustomWorldSceneNpcGeneration,
);
router.get(
'/runtime/save/snapshot',
routeMeta({ operation: 'runtime.snapshot.get' }),
asyncHandler(async (request, response) => {
sendApiResponse(
response,
hydrateSavedSnapshot(
await context.runtimeRepository.getSnapshot(request.userId!),
),
);
}),
);
router.put(
'/runtime/save/snapshot',
routeMeta({ operation: 'runtime.snapshot.put' }),
asyncHandler(async (request, response) => {
const payload = saveSnapshotSchema.parse(
request.body,
) as SavedGameSnapshotInput;
const normalizedSnapshot = normalizeSavedSnapshotPayload({
savedAt: payload.savedAt || new Date().toISOString(),
gameState: payload.gameState,
bottomTab: payload.bottomTab,
currentStory: payload.currentStory ?? null,
});
sendApiResponse(
response,
hydrateSavedSnapshot(
await context.runtimeRepository.putSnapshot(
request.userId!,
normalizedSnapshot,
),
),
);
}),
);
router.delete(
'/runtime/save/snapshot',
routeMeta({ operation: 'runtime.snapshot.delete' }),
asyncHandler(async (request, response) => {
await context.runtimeRepository.deleteSnapshot(request.userId!);
sendApiResponse(response, { ok: true });
}),
);
router.get(
'/runtime/settings',
routeMeta({ operation: 'runtime.settings.get' }),
asyncHandler(async (request, response) => {
sendApiResponse(
response,
await context.runtimeRepository.getSettings(request.userId!),
);
}),
);
router.put(
'/runtime/settings',
routeMeta({ operation: 'runtime.settings.put' }),
asyncHandler(async (request, response) => {
const payload = settingsSchema.parse(request.body) as RuntimeSettings;
sendApiResponse(
response,
await context.runtimeRepository.putSettings(request.userId!, payload),
);
}),
);
router.get(
'/runtime/custom-world/works',
routeMeta({ operation: 'runtime.customWorldWorks.list' }),
asyncHandler(async (request, response) => {
sendApiResponse<ListCustomWorldWorksResponse>(response, {
items: await listCustomWorldWorkSummaries(request.userId!, {
runtimeRepository: context.runtimeRepository,
customWorldAgentSessions: context.customWorldAgentSessions,
}),
});
}),
);
router.get(
'/runtime/custom-world-library',
routeMeta({ operation: 'runtime.customWorldLibrary.list' }),
asyncHandler(async (request, response) => {
sendApiResponse(response, {
entries: await context.runtimeRepository.listCustomWorldProfiles(
request.userId!,
),
} satisfies CustomWorldLibraryResponse);
}),
);
router.put(
'/runtime/custom-world-library/:profileId',
routeMeta({ operation: 'runtime.customWorldLibrary.upsert' }),
asyncHandler(async (request, response) => {
const profileId = readParam(request.params.profileId);
if (!profileId) {
throw badRequest('profileId is required');
}
const payload = customWorldProfileSchema.parse(request.body);
const authorDisplayName = await resolveAuthDisplayName(
context,
request.userId!,
);
sendApiResponse(
response,
await context.runtimeRepository.upsertCustomWorldProfile(
request.userId!,
profileId,
jsonClone(payload.profile),
authorDisplayName,
),
);
}),
);
router.delete(
'/runtime/custom-world-library/:profileId',
routeMeta({ operation: 'runtime.customWorldLibrary.delete' }),
asyncHandler(async (request, response) => {
const profileId = readParam(request.params.profileId);
if (!profileId) {
throw badRequest('profileId is required');
}
sendApiResponse(response, {
entries: await context.runtimeRepository.deleteCustomWorldProfile(
request.userId!,
profileId,
),
} satisfies CustomWorldLibraryResponse);
}),
);
router.post(
'/runtime/custom-world-library/:profileId/publish',
routeMeta({ operation: 'runtime.customWorldLibrary.publish' }),
asyncHandler(async (request, response) => {
const profileId = readParam(request.params.profileId);
if (!profileId) {
throw badRequest('profileId is required');
}
const authorDisplayName = await resolveAuthDisplayName(
context,
request.userId!,
);
const mutation =
await context.runtimeRepository.publishCustomWorldProfile(
request.userId!,
profileId,
authorDisplayName,
);
if (!mutation) {
throw notFound('custom world not found');
}
sendApiResponse(
response,
mutation satisfies CustomWorldLibraryMutationResponse,
);
}),
);
router.post(
'/runtime/custom-world-library/:profileId/unpublish',
routeMeta({ operation: 'runtime.customWorldLibrary.unpublish' }),
asyncHandler(async (request, response) => {
const profileId = readParam(request.params.profileId);
if (!profileId) {
throw badRequest('profileId is required');
}
const authorDisplayName = await resolveAuthDisplayName(
context,
request.userId!,
);
const mutation =
await context.runtimeRepository.unpublishCustomWorldProfile(
request.userId!,
profileId,
authorDisplayName,
);
if (!mutation) {
throw notFound('custom world not found');
}
sendApiResponse(
response,
mutation satisfies CustomWorldLibraryMutationResponse,
);
}),
);
router.post(
'/runtime/story/initial',
routeMeta({ operation: 'runtime.story.initial' }),
asyncHandler(async (request, response) => {
const payload = parseStoryRequest(request.body);
sendApiResponse(
response,
await generateHighQualityInitialStory(context.llmClient, payload),
);
}),
);
router.post(
'/runtime/story/continue',
routeMeta({ operation: 'runtime.story.continue' }),
asyncHandler(async (request, response) => {
const payload = parseStoryRequest(request.body);
sendApiResponse(
response,
await generateHighQualityNextStory(context.llmClient, payload),
);
}),
);
router.post(
'/runtime/chat/character/suggestions',
routeMeta({ operation: 'runtime.chat.character.suggestions' }),
asyncHandler(async (request, response) => {
const payload = characterChatSuggestionsRequestSchema.parse(
request.body,
) as CharacterChatSuggestionsRequest;
sendApiResponse(response, {
text: await generateCharacterChatSuggestionsFromOrchestrator(
context.llmClient,
payload,
),
});
}),
);
router.post(
'/runtime/chat/character/summary',
routeMeta({ operation: 'runtime.chat.character.summary' }),
asyncHandler(async (request, response) => {
const payload = characterChatSummaryRequestSchema.parse(
request.body,
) as CharacterChatSummaryRequest;
sendApiResponse(response, {
text: await generateCharacterChatSummaryFromOrchestrator(
context.llmClient,
payload,
),
});
}),
);
router.post(
'/runtime/chat/character/reply/stream',
routeMeta({ operation: 'runtime.chat.character.replyStream' }),
asyncHandler(async (request, response) => {
const payload = characterChatReplyRequestSchema.parse(
request.body,
) as CharacterChatReplyRequest;
await streamCharacterChatReplyFromOrchestrator(context.llmClient, {
request,
response,
payload,
});
}),
);
router.post(
'/runtime/chat/npc/dialogue/stream',
routeMeta({ operation: 'runtime.chat.npc.dialogueStream' }),
asyncHandler(async (request, response) => {
const payload = npcChatDialogueRequestSchema.parse(
request.body,
) as NpcChatDialogueRequest;
await streamNpcChatDialogueFromOrchestrator(context.llmClient, {
request,
response,
payload,
});
}),
);
router.post(
'/runtime/chat/npc/turn/stream',
routeMeta({ operation: 'runtime.chat.npc.turnStream' }),
asyncHandler(async (request, response) => {
const payload = npcChatTurnRequestSchema.parse(
request.body,
) as NpcChatTurnRequest;
await streamNpcChatTurnFromOrchestrator(context.llmClient, {
request,
response,
payload,
});
}),
);
router.post(
'/runtime/chat/npc/recruit/stream',
routeMeta({ operation: 'runtime.chat.npc.recruitStream' }),
asyncHandler(async (request, response) => {
const payload = npcRecruitDialogueRequestSchema.parse(
request.body,
) as NpcRecruitDialogueRequest;
await streamNpcRecruitDialogueFromOrchestrator(context.llmClient, {
request,
response,
payload,
});
}),
);
router.post(
'/runtime/custom-world/sessions',
routeMeta({ operation: 'runtime.customWorldSession.create' }),
asyncHandler(async (request, response) => {
const payload = customWorldSessionSchema.parse(
request.body,
) as CreateCustomWorldSessionRequest;
sendApiResponse(
response,
await context.customWorldSessions.create(
request.userId!,
payload.settingText,
payload.creatorIntent,
payload.generationMode,
),
);
}),
);
router.get(
'/runtime/custom-world/sessions/:sessionId',
routeMeta({ operation: 'runtime.customWorldSession.get' }),
asyncHandler(async (request, response) => {
const session = await context.customWorldSessions.get(
request.userId!,
readParam(request.params.sessionId),
);
if (!session) {
throw notFound('custom world session not found');
}
sendApiResponse(response, session);
}),
);
router.post(
'/runtime/custom-world/sessions/:sessionId/answers',
routeMeta({ operation: 'runtime.customWorldSession.answer' }),
asyncHandler(async (request, response) => {
const payload = customWorldAnswerSchema.parse(
request.body,
) as AnswerCustomWorldSessionQuestionRequest;
const session = await context.customWorldSessions.answer(
request.userId!,
readParam(request.params.sessionId),
payload.questionId,
payload.answer,
);
if (!session) {
throw notFound('custom world session not found');
}
sendApiResponse(response, session);
}),
);
router.get(
'/runtime/custom-world/sessions/:sessionId/generate/stream',
routeMeta({ operation: 'runtime.customWorldSession.generateStream' }),
asyncHandler(async (request, response) => {
const session = await context.customWorldSessions.get(
request.userId!,
readParam(request.params.sessionId),
);
if (!session) {
throw notFound('custom world session not found');
}
prepareEventStreamResponse(request, response);
const controller = new AbortController();
request.on('close', () => {
controller.abort();
});
const writeEvent = (event: string, payload: Record<string, unknown>) => {
response.write(`event: ${event}\n`);
response.write(`data: ${JSON.stringify(payload)}\n\n`);
};
writeEvent('progress', { phase: 'preparing', progress: 10 });
await context.customWorldSessions.updateStatus(
request.userId!,
readParam(request.params.sessionId),
'generating',
);
writeEvent('progress', { phase: 'requesting_llm', progress: 45 });
try {
const profile = await generateCustomWorldProfile(context, session, {
signal: controller.signal,
onProgress: (progress) => {
writeEvent(
'progress',
progress as unknown as Record<string, unknown>,
);
},
});
await context.customWorldSessions.setResult(
request.userId!,
readParam(request.params.sessionId),
profile,
);
writeEvent('progress', { phase: 'completed', progress: 100 });
writeEvent('result', { profile });
writeEvent('done', { ok: true });
} catch (error) {
const message =
error instanceof Error
? error.message
: 'custom world generation failed';
await context.customWorldSessions.updateStatus(
request.userId!,
readParam(request.params.sessionId),
'generation_error',
message,
);
writeEvent('error', { message });
} finally {
response.end();
}
}),
);
router.post(
'/runtime/items/runtime-intent',
routeMeta({ operation: 'runtime.items.intent' }),
asyncHandler(async (request, response) => {
const payload = runtimeItemIntentSchema.parse(
request.body,
) as RuntimeItemIntentRequest;
sendApiResponse(response, {
intents: await generateRuntimeItemIntents(context.llmClient, payload),
});
}),
);
router.post(
'/runtime/quests/generate',
routeMeta({ operation: 'runtime.quests.generate' }),
asyncHandler(async (request, response) => {
const payload = questGenerationSchema.parse(
request.body,
) as QuestGenerationRequest;
sendApiResponse(
response,
await generateQuestForNpcEncounter(context.llmClient, payload),
);
}),
);
router.get(
'/ws/health',
routeMeta({ operation: 'runtime.ws.health' }),
(_request, response) => {
sendApiResponse(response, {
ok: true,
message: 'websocket routes reserved for future real-time support',
});
},
);
return router;
}

View File

@@ -8,6 +8,13 @@ import { createLogger } from './logging.js';
import { AuthAuditLogRepository } from './repositories/authAuditLogRepository.js';
import { AuthIdentityRepository } from './repositories/authIdentityRepository.js';
import { AuthRiskBlockRepository } from './repositories/authRiskBlockRepository.js';
import { RpgAgentSessionRepository } from './repositories/RpgAgentSessionRepository.js';
import { RpgWorldProfileRepository } from './repositories/RpgWorldProfileRepository.js';
import { RpgSaveArchiveRepository } from './repositories/rpg-entry/RpgSaveArchiveRepository.js';
import { RpgWorldLibraryRepository } from './repositories/rpg-entry/RpgWorldLibraryRepository.js';
import { RpgBrowseHistoryRepository } from './repositories/rpg-profile/RpgBrowseHistoryRepository.js';
import { RpgProfileDashboardRepository } from './repositories/rpg-profile/RpgProfileDashboardRepository.js';
import { RpgRuntimeSnapshotRepository } from './repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js';
import { RuntimeRepository } from './repositories/runtimeRepository.js';
import { SmsAuthEventRepository } from './repositories/smsAuthEventRepository.js';
import { UserRepository } from './repositories/userRepository.js';
@@ -16,8 +23,8 @@ import { CaptchaChallengeStore } from './services/captchaChallengeStore.js';
import { CustomWorldAgentAutoAssetService } from './services/customWorldAgentAutoAssetService.js';
import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js';
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
import { UpstreamLlmClient } from './services/llmClient.js';
import { RpgWorldWorkSummaryService } from './services/RpgWorldWorkSummaryService.js';
import { createSmsVerificationService } from './services/smsVerificationService.js';
import { createWechatAuthService } from './services/wechatAuthService.js';
import { WechatAuthStateStore } from './services/wechatAuthStateStore.js';
@@ -80,10 +87,32 @@ function describeDatabase(databaseUrl: string) {
export async function createAppContext(config: AppConfig = loadConfig()) {
const logger = createLogger(config);
const db = await createDatabase(config);
const rpgAgentSessionRepository = new RpgAgentSessionRepository(db);
const rpgWorldProfileRepository = new RpgWorldProfileRepository(db);
const runtimeRepository = new RuntimeRepository(db);
const customWorldAgentSessions = new CustomWorldAgentSessionStore(
const rpgProfileDashboardRepository = new RpgProfileDashboardRepository(
runtimeRepository,
);
const rpgBrowseHistoryRepository = new RpgBrowseHistoryRepository(
runtimeRepository,
);
const rpgSaveArchiveRepository = new RpgSaveArchiveRepository(
runtimeRepository,
);
const rpgWorldLibraryRepository = new RpgWorldLibraryRepository(
runtimeRepository,
);
const rpgRuntimeSnapshotRepository = new RpgRuntimeSnapshotRepository(
runtimeRepository,
);
const userRepository = new UserRepository(db);
const customWorldAgentSessions = new CustomWorldAgentSessionStore(
rpgAgentSessionRepository,
);
const rpgWorldWorkSummaryService = new RpgWorldWorkSummaryService(
rpgWorldProfileRepository,
customWorldAgentSessions,
);
const autoAssetService = new CustomWorldAgentAutoAssetService(
config,
config.dashScope.apiKey.trim()
@@ -105,15 +134,21 @@ export async function createAppContext(config: AppConfig = loadConfig()) {
config,
logger,
db,
userRepository: new UserRepository(db),
userRepository,
authIdentityRepository: new AuthIdentityRepository(db),
authAuditLogRepository: new AuthAuditLogRepository(db),
authRiskBlockRepository: new AuthRiskBlockRepository(db),
smsAuthEventRepository: new SmsAuthEventRepository(db),
userSessionRepository: new UserSessionRepository(db),
rpgAgentSessionRepository,
rpgWorldProfileRepository,
rpgProfileDashboardRepository,
rpgBrowseHistoryRepository,
rpgSaveArchiveRepository,
rpgWorldLibraryRepository,
rpgRuntimeSnapshotRepository,
runtimeRepository,
llmClient: new UpstreamLlmClient(config, logger),
customWorldSessions: new CustomWorldSessionStore(runtimeRepository),
customWorldAgentSessions,
customWorldAgentOrchestrator: new CustomWorldAgentOrchestrator(
customWorldAgentSessions,
@@ -122,8 +157,11 @@ export async function createAppContext(config: AppConfig = loadConfig()) {
: null,
{
autoAssetService,
rpgWorldProfileRepository,
userRepository,
},
),
rpgWorldWorkSummaryService,
smsVerificationService: createSmsVerificationService(config, logger),
wechatAuthService: createWechatAuthService(config, logger),
wechatAuthStates: new WechatAuthStateStore(),

View File

@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createRpgAgentFoundationDraftProfileFixture,
createRpgCreationPublishedProfileFixture,
} from '../../../packages/shared/src/contracts/rpgCreationFixtures.js';
import {
buildRpgWorldPreviewEnvelope,
normalizeRpgWorldPreviewEnvelope,
} from './RpgWorldPreviewCompiler.js';
test('rpg world preview compiler can consume shared published profile fixture as a stable unit baseline', () => {
const publishedProfile = createRpgCreationPublishedProfileFixture();
const previewEnvelope = buildRpgWorldPreviewEnvelope(
publishedProfile,
String(publishedProfile.settingText ?? ''),
);
assert.equal(previewEnvelope.source, 'session_preview');
assert.equal(previewEnvelope.preview.name, publishedProfile.name);
assert.equal(
(previewEnvelope.preview.playableNpcs as Array<{ generatedAnimationSetId?: string }>)[0]
?.generatedAnimationSetId,
'animation-set-playable-1',
);
assert.equal(
(
previewEnvelope.preview.sceneChapterBlueprints as Array<{
acts?: Array<{ backgroundImageSrc?: string }>;
}>
)[0]?.acts?.[0]?.backgroundImageSrc,
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
);
});
test('regression: foundation-like shared fixture fields are preserved after normalize + preview compile chain', () => {
const foundationDraft = createRpgAgentFoundationDraftProfileFixture();
const normalizedPreviewEnvelope = normalizeRpgWorldPreviewEnvelope(
{
name: foundationDraft.name,
subtitle: foundationDraft.subtitle,
summary: foundationDraft.summary,
tone: foundationDraft.tone,
playerGoal: foundationDraft.playerGoal,
templateWorldType: 'WUXIA',
majorFactions: foundationDraft.majorFactions,
coreConflicts: foundationDraft.coreConflicts,
playableNpcs: foundationDraft.playableNpcs,
storyNpcs: foundationDraft.storyNpcs,
camp: foundationDraft.camp,
landmarks: foundationDraft.landmarks,
sceneChapterBlueprints: foundationDraft.sceneChapters,
themePack: foundationDraft.themePack,
storyGraph: foundationDraft.storyGraph,
},
foundationDraft.worldHook,
);
assert.equal(normalizedPreviewEnvelope.source, 'session_preview');
assert.equal(
(normalizedPreviewEnvelope.preview.playableNpcs as Array<{ imageSrc?: string }>)[0]
?.imageSrc,
'/generated-characters/playable-1/visual/asset-runtime/master.png',
);
assert.equal(
(normalizedPreviewEnvelope.preview.playableNpcs as Array<{
animationMap?: { attack?: { basePath?: string } };
}>)[0]?.animationMap?.attack?.basePath,
'/generated-characters/playable-1/animations/attack',
);
assert.equal(
(
normalizedPreviewEnvelope.preview.sceneChapterBlueprints as Array<{
acts?: Array<{ backgroundAssetId?: string }>;
}>
)[0]?.acts?.[0]?.backgroundAssetId,
'scene-asset-runtime',
);
});

View File

@@ -0,0 +1,269 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildRpgWorldPreviewEnvelope,
buildRpgWorldPreviewProfile,
normalizeRpgWorldPreviewEnvelope,
} from './RpgWorldPreviewCompiler.js';
import { buildRpgCreationPreviewProfileFromDraftProfile } from './rpgCreationPreviewProfileBuilder.js';
function createPreviewFixture() {
const storyNpcs = Array.from({ length: 25 }, (_, index) => ({
name: `场景角色${index + 1}`,
title: `头衔${index + 1}`,
role: `职责${index + 1}`,
description: `场景角色描述${index + 1}`,
backstory: `场景角色背景${index + 1}`,
personality: `场景角色性格${index + 1}`,
motivation: `场景角色动机${index + 1}`,
combatStyle: `场景角色战斗风格${index + 1}`,
initialAffinity: index % 4 === 0 ? -10 : 6,
relationshipHooks: [`关系${index + 1}`],
tags: [`线索${index + 1}`],
}));
return {
id: 'preview-world',
name: '预览测试世界',
subtitle: '预览副标题',
summary: '服务端预览编译的兼容结果。',
tone: '压抑、潮湿',
playerGoal: '先确认谁在推动局势,再决定站位。',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '潮线商盟'],
coreConflicts: ['旧航道解释权正在被重写'],
playableNpcs: Array.from({ length: 5 }, (_, index) => ({
name: `角色${index + 1}`,
title: `称号${index + 1}`,
role: `身份${index + 1}`,
description: `角色描述${index + 1}`,
backstory: `角色背景${index + 1}`,
personality: `角色性格${index + 1}`,
motivation: `角色动机${index + 1}`,
combatStyle: `战斗风格${index + 1}`,
initialAffinity: 18,
relationshipHooks: [`接触点${index + 1}`],
tags: [`标签${index + 1}`],
})),
storyNpcs,
landmarks: Array.from({ length: 10 }, (_, index) => ({
name: `场景${index + 1}`,
description: `场景描述${index + 1}`,
dangerLevel: 'medium',
sceneNpcNames: [
storyNpcs[index % storyNpcs.length]?.name ?? `场景角色${index + 1}`,
storyNpcs[(index + 1) % storyNpcs.length]?.name ??
`场景角色${index + 2}`,
storyNpcs[(index + 2) % storyNpcs.length]?.name ??
`场景角色${index + 3}`,
],
connections: [
{
targetLandmarkName: `场景${((index + 1) % 10) + 1}`,
relativePosition: 'forward',
summary: '沿主路前行',
},
{
targetLandmarkName: `场景${((index + 9) % 10) + 1}`,
relativePosition: 'back',
summary: '回身可返',
},
],
})),
};
}
test('rpg world preview compiler builds a legacy-compatible preview envelope on the server', () => {
const settingText = '一个被潮雾反复切开的边境世界。';
const rawProfile = createPreviewFixture();
const previewProfile = buildRpgWorldPreviewProfile(rawProfile, settingText);
const previewEnvelope = buildRpgWorldPreviewEnvelope(rawProfile, settingText);
const normalizedEnvelope = normalizeRpgWorldPreviewEnvelope(
rawProfile,
settingText,
);
assert.equal(previewProfile.name, '预览测试世界');
assert.equal(previewProfile.playableNpcs.length, 5);
assert.equal(previewEnvelope.source, 'session_preview');
assert.equal(normalizedEnvelope.source, 'session_preview');
assert.equal(previewEnvelope.preview.name, '预览测试世界');
assert.equal(previewEnvelope.preview.scenarioPackId, 'scenario-pack:预览测试世界');
assert.equal(
normalizedEnvelope.preview.campaignPackId,
'campaign-pack:预览测试世界',
);
});
test('phase5 preview builder keeps legacy runtime-rich fields while merging latest draft assets', () => {
const previewProfile = buildRpgCreationPreviewProfileFromDraftProfile({
sessionId: 'session-phase5-preview',
draftProfile: {
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
imageSrc: '/generated-characters/playable-1/visual/asset-runtime/master.png',
generatedVisualAssetId: 'asset-runtime-playable',
generatedAnimationSetId: 'animation-set-runtime-playable',
animationMap: {
attack: {
basePath: '/generated-characters/playable-1/animations/attack',
},
},
},
],
storyNpcs: [
{
id: 'story-1',
name: '顾潮音',
imageSrc: '/generated-characters/story-1/visual/asset-runtime/master.png',
generatedVisualAssetId: 'asset-runtime-story',
},
],
landmarks: [
{
id: 'landmark-1',
name: '回潮旧灯塔',
imageSrc: '/generated-custom-world-scenes/landmark-1/scene.png',
generatedSceneAssetId: 'scene-asset-runtime',
},
],
sceneChapters: [
{
id: 'scene-chapter-1',
sceneId: 'landmark-1',
title: '灯塔初章',
acts: [
{
id: 'scene-act-1',
title: '第一幕',
backgroundImageSrc:
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
backgroundAssetId: 'scene-act-runtime',
},
],
},
],
legacyResultProfile: {
id: 'agent-draft-session-phase5-preview',
settingText: '被海雾吞没的旧航路群岛',
name: '潮雾列岛·结果页精修版',
subtitle: '旧灯塔与失控航路',
summary: '服务端 preview 需要保留结果页富字段。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船夜与假航灯的真正操盘者。',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '旧航路引路人',
role: '关键同行者',
description: '最熟悉旧航路的人。',
backstory: '曾在沉船夜里带着半支船队逃出海雾。',
personality: '表面沉稳,心里一直在算退路。',
motivation: '想赶在守灯会封航前查清真相。',
combatStyle: '借地形和潮路换位,先拉扯再压近。',
initialAffinity: 18,
relationshipHooks: ['旧友', '沉船旧案'],
tags: ['潮路', '引路'],
narrativeProfile: {
publicMask: '像个只想把旧路再走通一次的熟路人。',
},
},
],
storyNpcs: [
{
id: 'story-1',
name: '顾潮音',
title: '守灯会值夜人',
role: '场景关键角色',
description: '夜里巡灯与封锁禁航区的人。',
backstory: '在失控海雾第一次吞船那夜,她是最后一个还留在灯塔顶的人。',
personality: '冷静克制,但提到旧灯册时会显得过分警觉。',
motivation: '想守住灯塔记录,也想找到谁先改动了禁航信号。',
combatStyle: '借塔顶视角和风向压制,再用灯火错位扰乱。',
initialAffinity: 8,
relationshipHooks: ['禁航记录', '灯塔值夜'],
tags: ['守灯会', '灯塔'],
},
],
items: [
{
id: 'item-world-1',
name: '潮雾罗盘',
category: '饰品',
},
],
landmarks: [
{
id: 'landmark-1',
name: '回潮旧灯塔',
description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。',
dangerLevel: 'high',
sceneNpcIds: ['story-1'],
connections: [],
},
],
themePack: {
id: 'theme-pack:tide',
},
knowledgeFacts: [
{
id: 'fact-1',
title: '高处潮痕',
},
],
threadContracts: [
{
id: 'contract-1',
threadId: 'thread-visible-1',
},
],
sceneChapterBlueprints: [
{
id: 'scene-chapter-1',
sceneId: 'landmark-1',
title: '灯塔初章',
acts: [
{
id: 'scene-act-1',
title: '第一幕',
},
],
},
],
generationMode: 'full',
generationStatus: 'complete',
},
},
});
assert.equal(previewProfile.name, '潮雾列岛');
assert.equal(previewProfile.playerGoal, '查清沉船与禁航区异动的真相。');
assert.equal(previewProfile.themePack?.id, 'theme-pack:tide');
assert.equal(previewProfile.knowledgeFacts?.[0]?.id, 'fact-1');
assert.equal(previewProfile.threadContracts?.[0]?.id, 'contract-1');
assert.equal(previewProfile.playableNpcs[0]?.imageSrc, '/generated-characters/playable-1/visual/asset-runtime/master.png');
assert.equal(previewProfile.playableNpcs[0]?.generatedAnimationSetId, 'animation-set-runtime-playable');
assert.equal(
previewProfile.playableNpcs[0]?.narrativeProfile?.publicMask,
'像个只想把旧路再走通一次的熟路人。',
);
assert.equal(
previewProfile.sceneChapterBlueprints?.[0]?.acts?.[0]?.backgroundAssetId,
'scene-act-runtime',
);
});

View File

@@ -0,0 +1,65 @@
import {
buildCompiledCustomWorldProfile,
normalizeCustomWorldProfile,
} from '../modules/custom-world/runtime-profile/index.js';
import type {
RpgCreationPreview,
RpgCreationPreviewEnvelope,
RpgCreationPreviewSource,
} from '../../../packages/shared/src/contracts/rpgCreationPreview.js';
import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js';
/**
* 工作包 G 把服务端结果预览编译入口收口到这里。
* Phase 5 后当前 preview 正式作为 session_preview 主链输出,
* 编译边界已经从 foundation draft 流程中抽离。
*/
export type RpgWorldPreviewProfile = CustomWorldProfile;
const RPG_WORLD_PREVIEW_SOURCE: RpgCreationPreviewSource =
'session_preview';
function toRpgCreationPreview(
profile: RpgWorldPreviewProfile,
): RpgCreationPreview {
return profile as unknown as RpgCreationPreview;
}
export function buildRpgWorldPreviewProfile(
raw: unknown,
settingText: string,
): RpgWorldPreviewProfile {
return buildCompiledCustomWorldProfile(raw, settingText);
}
export function normalizeRpgWorldPreviewProfile(
raw: unknown,
settingText: string,
): RpgWorldPreviewProfile {
return normalizeCustomWorldProfile(raw, settingText);
}
export function buildRpgWorldPreviewEnvelope(
raw: unknown,
settingText: string,
): RpgCreationPreviewEnvelope {
return {
preview: toRpgCreationPreview(buildRpgWorldPreviewProfile(raw, settingText)),
source: RPG_WORLD_PREVIEW_SOURCE,
};
}
export function normalizeRpgWorldPreviewEnvelope(
raw: unknown,
settingText: string,
): RpgCreationPreviewEnvelope {
return {
preview: toRpgCreationPreview(
buildRpgWorldPreviewProfile(
normalizeRpgWorldPreviewProfile(raw, settingText),
settingText,
),
),
source: RPG_WORLD_PREVIEW_SOURCE,
};
}

View File

@@ -0,0 +1,46 @@
import type {
CustomWorldLibraryEntry,
CustomWorldProfileRecord,
} from '../../../packages/shared/src/contracts/runtime.js';
import { resolveCustomWorldCoverPresentation } from '../repositories/customWorldLibraryMetadata.js';
import type { CustomWorldAgentSessionRecord } from './customWorldAgentSessionStore.js';
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
/**
* 作品封面解析统一收口在这里,避免 works 聚合服务重复理解草稿态与发布态的封面规则。
*/
export function resolveRpgWorldDraftWorkCover(
session: CustomWorldAgentSessionRecord,
) {
const draftProfile = toRecord(session.draftProfile);
if (!draftProfile) {
return {
imageSrc: null,
renderMode: 'image' as const,
characterImageSrcs: [],
};
}
return resolveCustomWorldCoverPresentation(
draftProfile as CustomWorldProfileRecord,
);
}
export function resolveRpgWorldPublishedWorkCover(
libraryEntry: CustomWorldLibraryEntry<CustomWorldProfileRecord>,
) {
const coverPresentation = resolveCustomWorldCoverPresentation(
libraryEntry.profile,
);
return {
imageSrc: libraryEntry.coverImageSrc || coverPresentation.imageSrc,
renderMode: coverPresentation.renderMode,
characterImageSrcs: coverPresentation.characterImageSrcs,
};
}

View File

@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createRpgAgentSessionFixture,
createRpgCreationWorksResponseFixture,
createRpgWorldLibraryEntryFixture,
} from '../../../packages/shared/src/contracts/rpgCreationFixtures.js';
import { RpgWorldWorkSummaryAssembler } from './RpgWorldWorkSummaryAssembler.js';
test('rpg world work summary assembler can consume shared fixture baselines as a unit test', () => {
const assembler = new RpgWorldWorkSummaryAssembler();
const session = createRpgAgentSessionFixture();
const libraryEntry = createRpgWorldLibraryEntryFixture();
const [draftItem] = assembler.assembleDraftItems([
{
...JSON.parse(JSON.stringify(session)),
userId: 'fixture-user',
seedText: '被海雾吞没的旧航路群岛',
operations: [],
checkpoints: [],
createdAt: session.updatedAt,
},
]);
const [publishedItem] = assembler.assemblePublishedItems([libraryEntry]);
assert.equal(draftItem.sourceType, 'agent_session');
assert.equal(draftItem.roleVisualReadyCount, 2);
assert.equal(draftItem.roleAnimationReadyCount, 2);
assert.equal(draftItem.roleAssetSummaryLabel, '沈砺 · 动作已就绪');
assert.equal(draftItem.canEnterWorld, false);
assert.equal(draftItem.publishReady, true);
assert.equal(draftItem.blockerCount, 0);
assert.equal(publishedItem.sourceType, 'published_profile');
assert.equal(publishedItem.canEnterWorld, true);
assert.equal(publishedItem.publishReady, true);
assert.equal(publishedItem.blockerCount, 0);
assert.equal(publishedItem.roleAnimationReadyCount, 1);
});
test('regression: assembler output stays aligned with shared works response fixture', () => {
const assembler = new RpgWorldWorkSummaryAssembler();
const session = createRpgAgentSessionFixture();
const libraryEntry = createRpgWorldLibraryEntryFixture();
const expected = createRpgCreationWorksResponseFixture();
const [draftItem] = assembler.assembleDraftItems([
{
...JSON.parse(JSON.stringify(session)),
userId: 'fixture-user',
seedText: '被海雾吞没的旧航路群岛',
operations: [],
checkpoints: [],
createdAt: session.updatedAt,
},
]);
const [publishedItem] = assembler.assemblePublishedItems([libraryEntry]);
const expectedDraft = expected.items.find((entry) => entry.sourceType === 'agent_session');
const expectedPublished = expected.items.find(
(entry) => entry.sourceType === 'published_profile',
);
assert.ok(expectedDraft);
assert.ok(expectedPublished);
assert.equal(draftItem.coverImageSrc, expectedDraft.coverImageSrc);
assert.deepEqual(
draftItem.coverCharacterImageSrcs,
expectedDraft.coverCharacterImageSrcs,
);
assert.equal(draftItem.stageLabel, expectedDraft.stageLabel);
assert.equal(draftItem.publishReady, expectedDraft.publishReady);
assert.equal(draftItem.blockerCount, expectedDraft.blockerCount);
assert.equal(publishedItem.coverImageSrc, expectedPublished.coverImageSrc);
assert.equal(
publishedItem.roleAssetSummaryLabel,
expectedPublished.roleAssetSummaryLabel,
);
});
test('published sessions do not leak back into draft work summaries', () => {
const assembler = new RpgWorldWorkSummaryAssembler();
const session = createRpgAgentSessionFixture();
const draftItems = assembler.assembleDraftItems([
{
...JSON.parse(JSON.stringify(session)),
userId: 'fixture-user',
seedText: '被海雾吞没的旧航路群岛',
operations: [],
checkpoints: [],
createdAt: session.updatedAt,
stage: 'published',
},
]);
assert.equal(draftItems.length, 0);
});

View File

@@ -0,0 +1,301 @@
import type {
CustomWorldAgentStage,
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type {
CustomWorldLibraryEntry,
CustomWorldProfileRecord,
} from '../../../packages/shared/src/contracts/runtime.js';
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
import {
buildDraftSummaryFromIntent,
buildDraftTitleFromIntent,
normalizeCreatorIntentRecord,
} from './customWorldAgentIntentExtractionService.js';
import {
rebuildRoleAssetCoverage,
resolveRoleAssetStatusLabel,
} from './customWorldAgentRoleAssetStateService.js';
import type { CustomWorldAgentSessionRecord } from './customWorldAgentSessionStore.js';
import {
buildDraftSummaryFromEightAnchorContent,
buildDraftTitleFromEightAnchorContent,
} from './eightAnchorCompatibilityService.js';
import {
resolveRpgWorldDraftWorkCover,
resolveRpgWorldPublishedWorkCover,
} from './RpgWorldWorkCoverResolver.js';
import { CustomWorldAgentPublishingService } from './customWorldAgentPublishingService.js';
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function toRecordArray(value: unknown) {
return Array.isArray(value)
? value.filter((item) => item && typeof item === 'object')
: [];
}
function truncateText(value: string, maxLength: number) {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, Math.max(0, maxLength - 1)).trim()}`;
}
function formatDraftStageLabel(stage: CustomWorldAgentStage) {
if (stage === 'collecting_intent') return '收集世界锚点';
if (stage === 'clarifying') return '补齐关键锚点';
if (stage === 'foundation_review') return '准备整理底稿';
if (stage === 'object_refining') return '待完善草稿';
if (stage === 'visual_refining') return '视觉工坊';
if (stage === 'long_tail_review') return '扩展长尾';
if (stage === 'ready_to_publish') return '准备发布';
if (stage === 'published') return '已发布';
return '发生错误';
}
function resolveDraftTitle(session: CustomWorldAgentSessionRecord) {
const intent = normalizeCreatorIntentRecord(session.creatorIntent);
const draftProfile = normalizeFoundationDraftProfile(session.draftProfile);
return (
draftProfile?.name ||
buildDraftTitleFromEightAnchorContent(session.anchorContent) ||
buildDraftTitleFromIntent(intent) ||
toText(session.draftProfile?.title) ||
truncateText(session.seedText, 18) ||
'未命名草稿'
);
}
function resolveDraftSummary(session: CustomWorldAgentSessionRecord) {
const intent = normalizeCreatorIntentRecord(session.creatorIntent);
const compiledSummary = buildDraftSummaryFromIntent(intent);
const draftProfile = normalizeFoundationDraftProfile(session.draftProfile);
return (
draftProfile?.summary ||
buildDraftSummaryFromEightAnchorContent(session.anchorContent) ||
compiledSummary ||
toText(session.draftProfile?.summary) ||
truncateText(session.seedText, 72) ||
'还在收集你的世界锚点。'
);
}
function resolveDraftCounts(session: CustomWorldAgentSessionRecord) {
const draftProfile = normalizeFoundationDraftProfile(session.draftProfile);
if (draftProfile) {
// 草稿作品卡需要展示当前可编辑的全部角色数量,而不是仅统计可扮演角色。
const totalRoleCount = [
...new Set(
[...draftProfile.playableNpcs, ...draftProfile.storyNpcs].map(
(entry) => entry.id,
),
),
].length;
return {
playableNpcCount: totalRoleCount,
landmarkCount: draftProfile.landmarks.length,
};
}
const playableNpcCount = session.draftCards.filter(
(card) => card.kind === 'character',
).length;
const landmarkCount = session.draftCards.filter(
(card) => card.kind === 'landmark' || card.kind === 'camp',
).length;
return {
playableNpcCount,
landmarkCount,
};
}
function resolveDraftRoleAssetProgress(session: CustomWorldAgentSessionRecord) {
const coverage = rebuildRoleAssetCoverage(session.draftProfile);
const roleVisualReadyCount = coverage.roleAssets.filter(
(entry) => entry.status !== 'missing',
).length;
const roleAnimationReadyCount = coverage.roleAssets.filter(
(entry) => entry.status === 'complete',
).length;
const leadRole = coverage.roleAssets[0];
return {
roleVisualReadyCount,
roleAnimationReadyCount,
roleAssetSummaryLabel: leadRole
? `${leadRole.roleName} · ${resolveRoleAssetStatusLabel(leadRole.status)}`
: coverage.roleAssets.length > 0
? '角色资产进行中'
: null,
};
}
function isLibraryEntry(
value: unknown,
): value is CustomWorldLibraryEntry<CustomWorldProfileRecord> {
const record = toRecord(value);
return (
record !== null &&
typeof record.ownerUserId === 'string' &&
typeof record.profileId === 'string' &&
Boolean(toRecord(record.profile))
);
}
function isPublishedLibraryEntry(
value: unknown,
): value is CustomWorldLibraryEntry<CustomWorldProfileRecord> {
return isLibraryEntry(value) && value.visibility === 'published';
}
/**
* works 组装器只负责把 session/profile 转成稳定读模型,不直接发起仓储读取。
*/
export class RpgWorldWorkSummaryAssembler {
private readonly publishGateService = new CustomWorldAgentPublishingService({
listOwnProfiles: async () => [],
upsertOwnProfile: async () => {
throw new Error('publish repository is unavailable in work summary assembler');
},
syncProfileFromSnapshot: async () => undefined,
softDeleteOwnProfile: async () => [],
publishOwnProfile: async () => null,
unpublishOwnProfile: async () => null,
listPublishedGallery: async () => [],
getPublishedGalleryDetail: async () => null,
});
assembleDraftItems(sessions: CustomWorldAgentSessionRecord[]) {
return sessions
.filter((session) => session.stage !== 'published')
.map((session) => {
const counts = resolveDraftCounts(session);
const roleAssetProgress = resolveDraftRoleAssetProgress(session);
const coverPresentation = resolveRpgWorldDraftWorkCover(session);
const publishState = this.publishGateService.summarizePublishGate({
sessionId: session.sessionId,
stage: session.stage,
draftProfile: session.draftProfile,
qualityFindings: session.qualityFindings,
});
return {
workId: `draft:${session.sessionId}`,
sourceType: 'agent_session',
status: 'draft',
title: resolveDraftTitle(session),
subtitle:
normalizeFoundationDraftProfile(session.draftProfile)?.subtitle ||
formatDraftStageLabel(session.stage),
summary: resolveDraftSummary(session),
coverImageSrc: coverPresentation.imageSrc,
coverRenderMode: coverPresentation.renderMode,
coverCharacterImageSrcs: coverPresentation.characterImageSrcs,
updatedAt: session.updatedAt,
publishedAt: null,
stage: session.stage,
stageLabel: formatDraftStageLabel(session.stage),
playableNpcCount: counts.playableNpcCount,
landmarkCount: counts.landmarkCount,
roleVisualReadyCount: roleAssetProgress.roleVisualReadyCount,
roleAnimationReadyCount: roleAssetProgress.roleAnimationReadyCount,
roleAssetSummaryLabel: roleAssetProgress.roleAssetSummaryLabel,
sessionId: session.sessionId,
profileId: null,
canResume: true,
canEnterWorld: publishState.canEnterWorld,
blockerCount: publishState.blockerCount,
publishReady: publishState.publishReady,
} satisfies CustomWorldWorkSummary;
});
}
assemblePublishedItems(
profiles: Array<CustomWorldLibraryEntry<CustomWorldProfileRecord>>,
) {
return profiles.filter(isPublishedLibraryEntry).map((libraryEntry) => {
const profileRecord = libraryEntry.profile as CustomWorldProfileRecord &
Record<string, unknown>;
const playableNpcs = toRecordArray(profileRecord.playableNpcs);
const landmarks = toRecordArray(profileRecord.landmarks);
const updatedAt =
toText(libraryEntry.updatedAt) ||
toText(profileRecord.updatedAt) ||
new Date().toISOString();
const coverPresentation = resolveRpgWorldPublishedWorkCover(libraryEntry);
const roleVisualReadyCount = playableNpcs.filter(
(entry) =>
Boolean(toText(entry.imageSrc)) &&
Boolean(toText(entry.generatedVisualAssetId)),
).length;
const roleAnimationReadyCount = playableNpcs.filter((entry) =>
Boolean(toText(entry.generatedAnimationSetId)),
).length;
return {
workId: `published:${toText(profileRecord.id) || updatedAt}`,
sourceType: 'published_profile',
status: 'published',
title:
toText(libraryEntry.worldName) ||
toText(profileRecord.name) ||
'未命名世界',
subtitle:
toText(libraryEntry.subtitle) ||
toText(profileRecord.subtitle) ||
'已保存作品',
summary:
toText(libraryEntry.summaryText) ||
toText(profileRecord.summary) ||
'这个世界已经可以直接进入体验。',
coverImageSrc: coverPresentation.imageSrc,
coverRenderMode: coverPresentation.renderMode,
coverCharacterImageSrcs: coverPresentation.characterImageSrcs,
updatedAt,
publishedAt:
toText(libraryEntry.publishedAt) ||
toText(profileRecord.publishedAt) ||
updatedAt,
stage: 'published',
stageLabel: '已发布',
playableNpcCount:
libraryEntry.playableNpcCount > 0
? libraryEntry.playableNpcCount
: playableNpcs.length,
landmarkCount:
libraryEntry.landmarkCount > 0
? libraryEntry.landmarkCount
: landmarks.length,
roleVisualReadyCount,
roleAnimationReadyCount,
roleAssetSummaryLabel:
roleAnimationReadyCount > 0
? `动作已就绪 ${roleAnimationReadyCount}`
: roleVisualReadyCount > 0
? `主图已就绪 ${roleVisualReadyCount}`
: null,
sessionId: null,
profileId:
toText(libraryEntry.profileId) || toText(profileRecord.id) || null,
canResume: false,
canEnterWorld: true,
blockerCount: 0,
publishReady: true,
} satisfies CustomWorldWorkSummary;
});
}
}

View File

@@ -0,0 +1,44 @@
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js';
import type { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { RpgWorldWorkSummaryAssembler } from './RpgWorldWorkSummaryAssembler.js';
/**
* RPG 作品卡服务只负责组织“草稿 session + 已发布作品”两类读模型,
* 不再直接承担读库 SQL 或封面字段推导细节。
*/
export class RpgWorldWorkSummaryService {
private readonly assembler: RpgWorldWorkSummaryAssembler;
constructor(
private readonly rpgWorldProfiles: RpgWorldProfileRepositoryPort,
private readonly customWorldAgentSessions: CustomWorldAgentSessionStore,
assembler: RpgWorldWorkSummaryAssembler = new RpgWorldWorkSummaryAssembler(),
) {
this.assembler = assembler;
}
async list(userId: string): Promise<CustomWorldWorkSummary[]> {
const [sessions, profiles] = await Promise.all([
this.customWorldAgentSessions.list(userId),
this.rpgWorldProfiles.listOwnProfiles(userId),
]);
const draftItems = this.assembler.assembleDraftItems(sessions);
const publishedItems = this.assembler.assemblePublishedItems(profiles);
return [...draftItems, ...publishedItems].sort((left, right) => {
const updatedAtDiff =
new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime();
if (updatedAtDiff !== 0) {
return updatedAtDiff;
}
if (left.sourceType !== right.sourceType) {
return left.sourceType === 'agent_session' ? -1 : 1;
}
return left.workId.localeCompare(right.workId);
});
}
}

View File

@@ -7,7 +7,7 @@ import type {
NpcChatDialogueRequest,
NpcChatTurnRequest,
NpcRecruitDialogueRequest,
} from '../../../packages/shared/src/contracts/story.js';
} from '../../../packages/shared/src/contracts/rpgRuntimeChat.js';
const jsonObjectSchema = z.record(z.string(), z.unknown());

View File

@@ -0,0 +1,145 @@
import {
buildCreatorIntentFromEightAnchorContent,
buildAnchorPackFromEightAnchorContent,
} from '../eightAnchorCompatibilityService.js';
import { rebuildRoleAssetCoverage } from '../customWorldAgentRoleAssetStateService.js';
import type { CustomWorldAgentActionExecutor } from './types.js';
import type { CustomWorldAgentFoundationDraftService } from '../customWorldAgentFoundationDraftService.js';
import type { CustomWorldAgentAutoAssetService } from '../customWorldAgentAutoAssetService.js';
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
import { buildFoundationDraftAssistantMessage } from './helpers.js';
import {
buildCheckpointSnapshot,
createOperationUpdater,
getRequiredSession,
} from './executorShared.js';
export function createDraftFoundationExecutor(params: {
sessionStore: CustomWorldAgentSessionStore;
foundationDraftService: CustomWorldAgentFoundationDraftService;
autoAssetService: CustomWorldAgentAutoAssetService | null;
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
}): CustomWorldAgentActionExecutor<'draft_foundation'> {
return async ({ userId, sessionId, operationId }) => {
const updateOperation = createOperationUpdater({
sessionStore: params.sessionStore,
userId,
sessionId,
operationId,
});
try {
await updateOperation({
status: 'running',
phaseLabel: '整理世界骨架',
phaseDetail: '正在校验已确认锚点,并准备第一版世界框架生成链路。',
progress: 12,
});
const latestSession = await getRequiredSession({
sessionStore: params.sessionStore,
userId,
sessionId,
});
if (latestSession.progressPercent < 100) {
throw new Error('session progressPercent is below 100');
}
const creatorIntent = buildCreatorIntentFromEightAnchorContent(
latestSession.anchorContent,
);
const anchorPack = buildAnchorPackFromEightAnchorContent(
latestSession.anchorContent,
latestSession.progressPercent,
);
const draftProfile = await params.foundationDraftService.generate({
creatorIntent,
anchorPack,
anchorContent: latestSession.anchorContent,
onProgress: async (progress) => {
await updateOperation({
status: 'running',
phaseLabel: progress.phaseLabel,
phaseDetail: progress.phaseDetail,
progress: progress.progress,
});
},
});
const draftWithAssets = params.autoAssetService
? await params.autoAssetService.populateDraftAssets({
draftProfile,
onProgress: async (progress) => {
await updateOperation({
status: 'running',
phaseLabel: progress.phaseLabel,
phaseDetail: progress.phaseDetail,
progress: progress.progress,
});
},
})
: {
draftProfile,
assetCoverage: rebuildRoleAssetCoverage(draftProfile),
warnings: [],
};
await updateOperation({
phaseLabel: '编译草稿卡',
phaseDetail: '正在把世界底稿整理成可浏览的卡片摘要和详情结构。',
progress: 98,
});
const nextState = params.snapshotBuilder.buildFoundationDraftState({
creatorIntent,
anchorPack,
draftProfile:
draftWithAssets.draftProfile as unknown as Record<string, unknown>,
assetCoverage: draftWithAssets.assetCoverage,
});
await params.sessionStore.replaceDerivedState(userId, sessionId, nextState);
await params.sessionStore.appendCheckpoint(userId, sessionId, {
label: '世界底稿 V1',
snapshot: buildCheckpointSnapshot(latestSession, nextState),
});
await params.sessionStore.appendMessage(
userId,
sessionId,
buildFoundationDraftAssistantMessage({
relatedOperationId: operationId,
draftProfile: draftWithAssets.draftProfile,
warnings: draftWithAssets.warnings,
}),
);
await updateOperation({
status: 'completed',
phaseLabel: '世界底稿已生成',
phaseDetail:
draftWithAssets.warnings.length > 0
? `第一版世界底稿和 ${(nextState.draftCards ?? []).length} 张草稿卡已经整理完成,另有 ${draftWithAssets.warnings.length} 项资产补齐待后续处理。`
: `第一版世界底稿和 ${(nextState.draftCards ?? []).length} 张草稿卡已经整理完成。`,
progress: 100,
error: null,
});
} catch (error) {
const currentOperation = await params.sessionStore.getOperation(
userId,
sessionId,
operationId,
);
await updateOperation({
status: 'failed',
phaseLabel: currentOperation?.phaseLabel?.trim() || '底稿生成失败',
phaseDetail:
currentOperation?.phaseDetail?.trim() ||
'这一轮没有成功把设定编成世界底稿。',
progress: 100,
error:
error instanceof Error ? error.message : 'draft foundation failed',
});
}
};
}

View File

@@ -0,0 +1,108 @@
import type { CustomWorldAgentOperationRecord } from '../../../../packages/shared/src/contracts/customWorldAgent.js';
import type { CustomWorldAgentSessionRecord, CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
export type UpdateExecutorOperation = (
patch: Partial<CustomWorldAgentOperationRecord>,
) => Promise<void>;
export async function getRequiredSession(params: {
sessionStore: CustomWorldAgentSessionStore;
userId: string;
sessionId: string;
}) {
const session = (await params.sessionStore.get(
params.userId,
params.sessionId,
)) as CustomWorldAgentSessionRecord | null;
if (!session) {
throw new Error('custom world agent session not found');
}
return session;
}
export function createOperationUpdater(params: {
sessionStore: CustomWorldAgentSessionStore;
userId: string;
sessionId: string;
operationId: string;
}): UpdateExecutorOperation {
return (patch) =>
params.sessionStore.updateOperation(
params.userId,
params.sessionId,
params.operationId,
patch,
);
}
// checkpoint 恢复依赖这份最小可回放快照,统一由 executor 共享,避免每个动作手写字段集合。
export function buildCheckpointSnapshot(
session: CustomWorldAgentSessionRecord,
patch: Partial<
Pick<
CustomWorldAgentSessionRecord,
| 'currentTurn'
| 'anchorContent'
| 'progressPercent'
| 'lastAssistantReply'
| 'stage'
| 'focusCardId'
| 'creatorIntent'
| 'creatorIntentReadiness'
| 'anchorPack'
| 'lockState'
| 'draftProfile'
| 'pendingClarifications'
| 'suggestedActions'
| 'recommendedReplies'
| 'draftCards'
| 'qualityFindings'
| 'assetCoverage'
>
>,
) {
return {
currentTurn: patch.currentTurn ?? session.currentTurn,
anchorContent: patch.anchorContent ?? session.anchorContent,
progressPercent: patch.progressPercent ?? session.progressPercent,
lastAssistantReply:
patch.lastAssistantReply !== undefined
? patch.lastAssistantReply
: session.lastAssistantReply,
stage: patch.stage ?? session.stage,
focusCardId:
patch.focusCardId !== undefined ? patch.focusCardId : session.focusCardId,
creatorIntent:
patch.creatorIntent !== undefined
? patch.creatorIntent
: session.creatorIntent,
creatorIntentReadiness:
patch.creatorIntentReadiness ?? session.creatorIntentReadiness,
anchorPack: patch.anchorPack !== undefined ? patch.anchorPack : session.anchorPack,
lockState: patch.lockState !== undefined ? patch.lockState : session.lockState,
draftProfile:
patch.draftProfile !== undefined ? patch.draftProfile : session.draftProfile,
pendingClarifications:
patch.pendingClarifications !== undefined
? patch.pendingClarifications
: session.pendingClarifications,
suggestedActions:
patch.suggestedActions !== undefined
? patch.suggestedActions
: session.suggestedActions,
recommendedReplies:
patch.recommendedReplies !== undefined
? patch.recommendedReplies
: session.recommendedReplies,
draftCards: patch.draftCards !== undefined ? patch.draftCards : session.draftCards,
qualityFindings:
patch.qualityFindings !== undefined
? patch.qualityFindings
: session.qualityFindings,
assetCoverage:
patch.assetCoverage !== undefined
? patch.assetCoverage
: session.assetCoverage,
};
}

View File

@@ -0,0 +1,116 @@
import { getWorldFoundationCardId } from '../customWorldAgentDraftCompiler.js';
import type { CustomWorldAgentActionExecutor } from './types.js';
import type { CustomWorldAgentEntityGenerationService } from '../customWorldAgentEntityGenerationService.js';
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
import { buildActionResultMessage } from './helpers.js';
import {
buildCheckpointSnapshot,
createOperationUpdater,
getRequiredSession,
} from './executorShared.js';
export function createExpandLongTailExecutor(params: {
sessionStore: CustomWorldAgentSessionStore;
entityGenerationService: CustomWorldAgentEntityGenerationService;
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
}): CustomWorldAgentActionExecutor<'expand_long_tail'> {
return async ({ userId, sessionId, operationId }) => {
const updateOperation = createOperationUpdater({
sessionStore: params.sessionStore,
userId,
sessionId,
operationId,
});
try {
await updateOperation({
status: 'running',
phaseLabel: '扩展长尾内容',
phaseDetail: '正在补充边缘角色与次级地点,让世界草稿更完整可玩。',
progress: 28,
});
const latestSession = await getRequiredSession({
sessionStore: params.sessionStore,
userId,
sessionId,
});
const baseDraftProfile =
(latestSession.draftProfile ?? {}) as Record<string, unknown>;
const characterResult =
await params.entityGenerationService.generateAdditionalCharacters({
creatorIntent: latestSession.creatorIntent,
anchorPack: latestSession.anchorPack,
draftProfile: baseDraftProfile,
count: 2,
anchorCardIds:
latestSession.focusCardId && latestSession.focusCardId.trim()
? [latestSession.focusCardId]
: [getWorldFoundationCardId()],
});
await updateOperation({
phaseLabel: '补充次级地点',
phaseDetail: '正在围绕新线索补齐可承接支线与长尾内容的地点。',
progress: 62,
});
const landmarkResult =
await params.entityGenerationService.generateAdditionalLandmarks({
creatorIntent: latestSession.creatorIntent,
anchorPack: latestSession.anchorPack,
draftProfile: characterResult.draftProfile,
count: 2,
anchorCardIds:
characterResult.generatedCharacters.length > 0
? [characterResult.generatedCharacters[0]!.id]
: latestSession.focusCardId && latestSession.focusCardId.trim()
? [latestSession.focusCardId]
: [getWorldFoundationCardId()],
});
const focusCardId =
landmarkResult.generatedLandmarks[0]?.id ??
characterResult.generatedCharacters[0]?.id ??
latestSession.focusCardId;
const nextState = params.snapshotBuilder.buildRefiningState({
previousStage: latestSession.stage,
nextStage: 'long_tail_review',
draftProfile: landmarkResult.draftProfile,
focusCardId,
});
await params.sessionStore.replaceDerivedState(userId, sessionId, nextState);
await params.sessionStore.appendCheckpoint(userId, sessionId, {
label: `扩展长尾 ${characterResult.generatedCharacters.length} 角色 / ${landmarkResult.generatedLandmarks.length} 地点`,
snapshot: buildCheckpointSnapshot(latestSession, nextState),
});
await params.sessionStore.appendMessage(
userId,
sessionId,
buildActionResultMessage({
relatedOperationId: operationId,
text: `已补出 ${characterResult.generatedCharacters.length} 个长尾角色和 ${landmarkResult.generatedLandmarks.length} 个次级地点,当前阶段进入补全长尾内容。`,
}),
);
await updateOperation({
status: 'completed',
phaseLabel: '长尾内容已扩展',
phaseDetail: '长尾角色与次级地点已经补回草稿,可继续收口后进入发布前检查。',
progress: 100,
error: null,
});
} catch (error) {
await updateOperation({
status: 'failed',
phaseLabel: '扩展长尾失败',
phaseDetail: '这一轮没有成功补出长尾内容。',
progress: 100,
error:
error instanceof Error ? error.message : 'expand long tail failed',
});
}
};
}

View File

@@ -0,0 +1,110 @@
import { getWorldFoundationCardId } from '../customWorldAgentDraftCompiler.js';
import type { CustomWorldAgentActionExecutor } from './types.js';
import type { CustomWorldAgentEntityGenerationService } from '../customWorldAgentEntityGenerationService.js';
import type { CustomWorldAgentChangeSummaryService } from '../customWorldAgentChangeSummaryService.js';
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
import { buildActionResultMessage } from './helpers.js';
import {
buildCheckpointSnapshot,
createOperationUpdater,
getRequiredSession,
} from './executorShared.js';
export function createGenerateCharactersExecutor(params: {
sessionStore: CustomWorldAgentSessionStore;
entityGenerationService: CustomWorldAgentEntityGenerationService;
changeSummaryService: CustomWorldAgentChangeSummaryService;
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
}): CustomWorldAgentActionExecutor<'generate_characters'> {
return async ({ userId, sessionId, operationId, payload }) => {
const updateOperation = createOperationUpdater({
sessionStore: params.sessionStore,
userId,
sessionId,
operationId,
});
try {
await updateOperation({
status: 'running',
phaseLabel: '生成新角色',
phaseDetail: '正在围绕当前世界底稿补出新角色。',
progress: 32,
});
const latestSession = await getRequiredSession({
sessionStore: params.sessionStore,
userId,
sessionId,
});
const generationResult =
await params.entityGenerationService.generateAdditionalCharacters({
creatorIntent: latestSession.creatorIntent,
anchorPack: latestSession.anchorPack,
draftProfile: (latestSession.draftProfile ?? {}) as Record<
string,
unknown
>,
count: payload.count,
promptText: payload.promptText,
anchorCardIds:
payload.anchorCardIds && payload.anchorCardIds.length > 0
? payload.anchorCardIds
: latestSession.focusCardId
? [latestSession.focusCardId]
: [getWorldFoundationCardId()],
});
await updateOperation({
phaseLabel: '插入新角色卡',
phaseDetail: '正在把新角色插回草稿并刷新卡片列表。',
progress: 74,
});
const focusCardId = generationResult.generatedCharacters[0]?.id ?? null;
const nextState = params.snapshotBuilder.buildRefiningState({
previousStage: latestSession.stage,
draftProfile: generationResult.draftProfile,
focusCardId,
});
await params.sessionStore.replaceDerivedState(userId, sessionId, nextState);
await params.sessionStore.appendCheckpoint(userId, sessionId, {
label: `新增角色 ${generationResult.generatedCharacters.length}`,
snapshot: buildCheckpointSnapshot(latestSession, nextState),
});
await params.sessionStore.appendMessage(
userId,
sessionId,
buildActionResultMessage({
relatedOperationId: operationId,
text: params.changeSummaryService.buildSummary({
action: 'generate_characters',
names: generationResult.generatedCharacters.map(
(entry) => entry.name,
),
draftProfile: generationResult.draftProfile,
}),
}),
);
await updateOperation({
status: 'completed',
phaseLabel: '新角色已加入草稿',
phaseDetail: `已补出 ${generationResult.generatedCharacters.length} 个新角色。`,
progress: 100,
error: null,
});
} catch (error) {
await updateOperation({
status: 'failed',
phaseLabel: '角色生成失败',
phaseDetail: '这一轮没有成功补出新角色。',
progress: 100,
error:
error instanceof Error ? error.message : 'generate characters failed',
});
}
};
}

View File

@@ -0,0 +1,110 @@
import { getWorldFoundationCardId } from '../customWorldAgentDraftCompiler.js';
import type { CustomWorldAgentActionExecutor } from './types.js';
import type { CustomWorldAgentEntityGenerationService } from '../customWorldAgentEntityGenerationService.js';
import type { CustomWorldAgentChangeSummaryService } from '../customWorldAgentChangeSummaryService.js';
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
import { buildActionResultMessage } from './helpers.js';
import {
buildCheckpointSnapshot,
createOperationUpdater,
getRequiredSession,
} from './executorShared.js';
export function createGenerateLandmarksExecutor(params: {
sessionStore: CustomWorldAgentSessionStore;
entityGenerationService: CustomWorldAgentEntityGenerationService;
changeSummaryService: CustomWorldAgentChangeSummaryService;
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
}): CustomWorldAgentActionExecutor<'generate_landmarks'> {
return async ({ userId, sessionId, operationId, payload }) => {
const updateOperation = createOperationUpdater({
sessionStore: params.sessionStore,
userId,
sessionId,
operationId,
});
try {
await updateOperation({
status: 'running',
phaseLabel: '生成新地点',
phaseDetail: '正在围绕当前世界底稿补出新地点。',
progress: 32,
});
const latestSession = await getRequiredSession({
sessionStore: params.sessionStore,
userId,
sessionId,
});
const generationResult =
await params.entityGenerationService.generateAdditionalLandmarks({
creatorIntent: latestSession.creatorIntent,
anchorPack: latestSession.anchorPack,
draftProfile: (latestSession.draftProfile ?? {}) as Record<
string,
unknown
>,
count: payload.count,
promptText: payload.promptText,
anchorCardIds:
payload.anchorCardIds && payload.anchorCardIds.length > 0
? payload.anchorCardIds
: latestSession.focusCardId
? [latestSession.focusCardId]
: [getWorldFoundationCardId()],
});
await updateOperation({
phaseLabel: '插入新地点卡',
phaseDetail: '正在把新地点插回草稿并刷新卡片列表。',
progress: 74,
});
const focusCardId = generationResult.generatedLandmarks[0]?.id ?? null;
const nextState = params.snapshotBuilder.buildRefiningState({
previousStage: latestSession.stage,
draftProfile: generationResult.draftProfile,
focusCardId,
});
await params.sessionStore.replaceDerivedState(userId, sessionId, nextState);
await params.sessionStore.appendCheckpoint(userId, sessionId, {
label: `新增地点 ${generationResult.generatedLandmarks.length}`,
snapshot: buildCheckpointSnapshot(latestSession, nextState),
});
await params.sessionStore.appendMessage(
userId,
sessionId,
buildActionResultMessage({
relatedOperationId: operationId,
text: params.changeSummaryService.buildSummary({
action: 'generate_landmarks',
names: generationResult.generatedLandmarks.map(
(entry) => entry.name,
),
draftProfile: generationResult.draftProfile,
}),
}),
);
await updateOperation({
status: 'completed',
phaseLabel: '新地点已加入草稿',
phaseDetail: `已补出 ${generationResult.generatedLandmarks.length} 个新地点。`,
progress: 100,
error: null,
});
} catch (error) {
await updateOperation({
status: 'failed',
phaseLabel: '地点生成失败',
phaseDetail: '这一轮没有成功补出新地点。',
progress: 100,
error:
error instanceof Error ? error.message : 'generate landmarks failed',
});
}
};
}

View File

@@ -0,0 +1,82 @@
import type { CustomWorldAgentActionExecutor } from './types.js';
import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js';
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
import { buildActionResultMessage } from './helpers.js';
import {
createOperationUpdater,
getRequiredSession,
} from './executorShared.js';
export function createGenerateRoleAssetsExecutor(params: {
sessionStore: CustomWorldAgentSessionStore;
assetBridgeService: CustomWorldAgentAssetBridgeService;
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
}): CustomWorldAgentActionExecutor<'generate_role_assets'> {
return async ({ userId, sessionId, operationId, payload }) => {
const updateOperation = createOperationUpdater({
sessionStore: params.sessionStore,
userId,
sessionId,
operationId,
});
try {
await updateOperation({
status: 'running',
phaseLabel: '准备角色资产工坊',
phaseDetail: '正在校验角色并整理工坊上下文。',
progress: 40,
});
const latestSession = await getRequiredSession({
sessionStore: params.sessionStore,
userId,
sessionId,
});
const roleId = payload.roleIds[0]!;
const studioContext = params.assetBridgeService.buildRoleAssetStudioContext(
latestSession.draftProfile,
roleId,
);
const nextState = params.snapshotBuilder.buildRefiningState({
previousStage: latestSession.stage,
nextStage: 'visual_refining',
draftProfile:
(latestSession.draftProfile ?? {}) as Record<string, unknown>,
draftCards: latestSession.draftCards,
assetCoverage: latestSession.assetCoverage,
focusCardId: roleId,
});
await params.sessionStore.replaceDerivedState(userId, sessionId, nextState);
await params.sessionStore.appendMessage(
userId,
sessionId,
buildActionResultMessage({
relatedOperationId: operationId,
text: `已为「${studioContext.roleName}」准备好角色资产工坊,先生成主图候选,再补核心动作。`,
}),
);
await updateOperation({
status: 'completed',
phaseLabel: '角色资产工坊已就绪',
phaseDetail: `${studioContext.roleName}」现在可以开始生成主图和动作。`,
progress: 100,
error: null,
});
} catch (error) {
await updateOperation({
status: 'failed',
phaseLabel: '角色资产工坊准备失败',
phaseDetail: '这一轮没有成功进入角色资产工坊。',
progress: 100,
error:
error instanceof Error
? error.message
: 'generate role assets failed',
});
}
};
}

View File

@@ -0,0 +1,88 @@
import type { CustomWorldAgentActionExecutor } from './types.js';
import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js';
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
import { buildActionResultMessage } from './helpers.js';
import {
createOperationUpdater,
getRequiredSession,
} from './executorShared.js';
export function createGenerateSceneAssetsExecutor(params: {
sessionStore: CustomWorldAgentSessionStore;
assetBridgeService: CustomWorldAgentAssetBridgeService;
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
}): CustomWorldAgentActionExecutor<'generate_scene_assets'> {
return async ({ userId, sessionId, operationId, payload }) => {
const updateOperation = createOperationUpdater({
sessionStore: params.sessionStore,
userId,
sessionId,
operationId,
});
try {
await updateOperation({
status: 'running',
phaseLabel: '准备场景资产工坊',
phaseDetail: '正在校验目标场景并整理场景图工坊上下文。',
progress: 40,
});
const latestSession = await getRequiredSession({
sessionStore: params.sessionStore,
userId,
sessionId,
});
const sceneId = payload.sceneIds[0]!;
const sceneKind =
latestSession.draftCards.find((entry) => entry.id === sceneId)?.kind ===
'camp'
? 'camp'
: 'landmark';
const sceneContext = params.assetBridgeService.buildSceneAssetStudioContext(
latestSession.draftProfile,
sceneId,
sceneKind,
);
const nextState = params.snapshotBuilder.buildRefiningState({
previousStage: latestSession.stage,
nextStage: 'visual_refining',
draftProfile:
(latestSession.draftProfile ?? {}) as Record<string, unknown>,
draftCards: latestSession.draftCards,
assetCoverage: latestSession.assetCoverage,
focusCardId: sceneId,
});
await params.sessionStore.replaceDerivedState(userId, sessionId, nextState);
await params.sessionStore.appendMessage(
userId,
sessionId,
buildActionResultMessage({
relatedOperationId: operationId,
text: `已为「${sceneContext.sceneName}」准备好场景图工坊,保存生成结果后会自动同步回当前草稿。`,
}),
);
await updateOperation({
status: 'completed',
phaseLabel: '场景资产工坊已就绪',
phaseDetail: `${sceneContext.sceneName}」现在可以继续生成和确认正式场景图。`,
progress: 100,
error: null,
});
} catch (error) {
await updateOperation({
status: 'failed',
phaseLabel: '场景资产工坊准备失败',
phaseDetail: '这一轮没有成功进入场景资产工坊。',
progress: 100,
error:
error instanceof Error
? error.message
: 'generate scene assets failed',
});
}
};
}

View File

@@ -0,0 +1,58 @@
import crypto from 'node:crypto';
import type { CustomWorldAgentMessage } from '../../../../packages/shared/src/contracts/customWorldAgent.js';
import {
normalizeFoundationDraftProfile,
} from '../customWorldAgentDraftCompiler.js';
export function buildRoleAssetSyncResultText(params: {
roleName: string;
assetStatusLabel: string;
}) {
return `已把「${params.roleName}」的角色资产写回草稿,当前状态:${params.assetStatusLabel}`;
}
export function buildFoundationDraftAssistantMessage(params: {
relatedOperationId: string;
draftProfile: unknown;
warnings?: string[];
}) {
const profile = normalizeFoundationDraftProfile(params.draftProfile);
const leadCharacter = profile?.playableNpcs[0];
const leadLandmark = profile?.landmarks[0];
const warnings = (params.warnings ?? []).filter(Boolean);
return {
id: `message-${crypto.randomBytes(8).toString('hex')}`,
role: 'assistant',
kind: 'summary',
text: [
`我先把第一版世界底稿整理出来了:${profile?.summary || '底稿已经生成完成。'}`,
'',
`当前已经落下来的第一批对象数量是:关键角色 ${profile?.playableNpcs.length ?? 0} 个,关键地点 ${profile?.landmarks.length ?? 0} 个,势力 ${profile?.factions.length ?? 0} 个。`,
`建议你先从“${profile?.name || '世界总卡'}”这张世界总卡看起${leadCharacter ? `,再顺着角色「${leadCharacter.name}」往下细修` : ''}${leadLandmark ? `,地点可以先看「${leadLandmark.name}` : ''}`,
...(warnings.length > 0
? [
'',
`这一轮有 ${warnings.length} 项资产补齐未完成,但不影响世界底稿继续精修。`,
]
: []),
].join('\n'),
createdAt: new Date().toISOString(),
relatedOperationId: params.relatedOperationId,
} satisfies CustomWorldAgentMessage;
}
export function buildActionResultMessage(params: {
relatedOperationId: string;
text: string;
}) {
return {
id: `message-${crypto.randomBytes(8).toString('hex')}`,
role: 'assistant',
kind: 'action_result',
text: params.text,
createdAt: new Date().toISOString(),
relatedOperationId: params.relatedOperationId,
} satisfies CustomWorldAgentMessage;
}

View File

@@ -0,0 +1,105 @@
import type { CustomWorldAgentAutoAssetService } from '../customWorldAgentAutoAssetService.js';
import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js';
import type { CustomWorldAgentChangeSummaryService } from '../customWorldAgentChangeSummaryService.js';
import type { CustomWorldAgentDraftCompiler } from '../customWorldAgentDraftCompiler.js';
import type { CustomWorldAgentEntityGenerationService } from '../customWorldAgentEntityGenerationService.js';
import type { CustomWorldAgentFoundationDraftService } from '../customWorldAgentFoundationDraftService.js';
import type { CustomWorldAgentPublishingService } from '../customWorldAgentPublishingService.js';
import type { CustomWorldAgentResultSyncService } from '../customWorldAgentResultSyncService.js';
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
import { createDraftFoundationExecutor } from './draftFoundationExecutor.js';
import { createExpandLongTailExecutor } from './expandLongTailExecutor.js';
import { createGenerateCharactersExecutor } from './generateCharactersExecutor.js';
import { createGenerateLandmarksExecutor } from './generateLandmarksExecutor.js';
import { createGenerateRoleAssetsExecutor } from './generateRoleAssetsExecutor.js';
import { createGenerateSceneAssetsExecutor } from './generateSceneAssetsExecutor.js';
import { createPublishWorldExecutor } from './publishWorldExecutor.js';
import { createRevertCheckpointExecutor } from './revertCheckpointExecutor.js';
import { createSyncResultProfileExecutor } from './syncResultProfileExecutor.js';
import { createSyncRoleAssetsExecutor } from './syncRoleAssetsExecutor.js';
import { createSyncSceneAssetsExecutor } from './syncSceneAssetsExecutor.js';
import type { CustomWorldAgentActionExecutorMap } from './types.js';
import { createUpdateDraftCardExecutor } from './updateDraftCardExecutor.js';
export * from './types.js';
export function createCustomWorldAgentActionExecutorMap(params: {
sessionStore: CustomWorldAgentSessionStore;
foundationDraftService: CustomWorldAgentFoundationDraftService;
draftCompiler: CustomWorldAgentDraftCompiler;
entityGenerationService: CustomWorldAgentEntityGenerationService;
changeSummaryService: CustomWorldAgentChangeSummaryService;
assetBridgeService: CustomWorldAgentAssetBridgeService;
autoAssetService: CustomWorldAgentAutoAssetService | null;
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
resultSyncService: CustomWorldAgentResultSyncService;
publishingService: CustomWorldAgentPublishingService;
resolveAuthorDisplayName?: ((userId: string) => Promise<string>) | null;
}): CustomWorldAgentActionExecutorMap {
return {
draft_foundation: createDraftFoundationExecutor({
sessionStore: params.sessionStore,
foundationDraftService: params.foundationDraftService,
autoAssetService: params.autoAssetService,
snapshotBuilder: params.snapshotBuilder,
}),
update_draft_card: createUpdateDraftCardExecutor({
sessionStore: params.sessionStore,
draftCompiler: params.draftCompiler,
changeSummaryService: params.changeSummaryService,
snapshotBuilder: params.snapshotBuilder,
}),
sync_result_profile: createSyncResultProfileExecutor({
sessionStore: params.sessionStore,
resultSyncService: params.resultSyncService,
snapshotBuilder: params.snapshotBuilder,
}),
generate_characters: createGenerateCharactersExecutor({
sessionStore: params.sessionStore,
entityGenerationService: params.entityGenerationService,
changeSummaryService: params.changeSummaryService,
snapshotBuilder: params.snapshotBuilder,
}),
generate_landmarks: createGenerateLandmarksExecutor({
sessionStore: params.sessionStore,
entityGenerationService: params.entityGenerationService,
changeSummaryService: params.changeSummaryService,
snapshotBuilder: params.snapshotBuilder,
}),
generate_role_assets: createGenerateRoleAssetsExecutor({
sessionStore: params.sessionStore,
assetBridgeService: params.assetBridgeService,
snapshotBuilder: params.snapshotBuilder,
}),
sync_role_assets: createSyncRoleAssetsExecutor({
sessionStore: params.sessionStore,
assetBridgeService: params.assetBridgeService,
snapshotBuilder: params.snapshotBuilder,
}),
generate_scene_assets: createGenerateSceneAssetsExecutor({
sessionStore: params.sessionStore,
assetBridgeService: params.assetBridgeService,
snapshotBuilder: params.snapshotBuilder,
}),
sync_scene_assets: createSyncSceneAssetsExecutor({
sessionStore: params.sessionStore,
assetBridgeService: params.assetBridgeService,
snapshotBuilder: params.snapshotBuilder,
}),
expand_long_tail: createExpandLongTailExecutor({
sessionStore: params.sessionStore,
entityGenerationService: params.entityGenerationService,
snapshotBuilder: params.snapshotBuilder,
}),
publish_world: createPublishWorldExecutor({
sessionStore: params.sessionStore,
publishingService: params.publishingService,
resolveAuthorDisplayName: params.resolveAuthorDisplayName ?? null,
}),
revert_checkpoint: createRevertCheckpointExecutor({
sessionStore: params.sessionStore,
snapshotBuilder: params.snapshotBuilder,
}),
};
}

View File

@@ -0,0 +1,166 @@
import type { CustomWorldAgentActionExecutor } from './types.js';
import type { CustomWorldAgentPublishingService } from '../customWorldAgentPublishingService.js';
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
import { buildActionResultMessage } from './helpers.js';
import {
buildCheckpointSnapshot,
createOperationUpdater,
getRequiredSession,
} from './executorShared.js';
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function extractPublishBlockerMessages(message: string) {
const normalized = message.trim();
if (!normalized) {
return [];
}
const detailText = normalized.includes('')
? normalized.split('').slice(1).join('').trim()
: normalized;
return detailText
.split('')
.map((entry) => entry.trim())
.filter(Boolean);
}
function buildGateFailureMessage(errorMessage: string) {
return [
'当前世界还不能发布,先把这些阻断项补齐:',
...(extractPublishBlockerMessages(errorMessage).length > 0
? extractPublishBlockerMessages(errorMessage)
: [errorMessage.trim()]
)
.slice(0, 4)
.map((entry, index) => `${index + 1}. ${entry}`),
].join('\n');
}
function resolvePublishedWorldName(profile: unknown) {
const profileRecord =
profile && typeof profile === 'object' && !Array.isArray(profile)
? (profile as Record<string, unknown>)
: null;
return toText(profileRecord?.name) || '当前世界';
}
export function createPublishWorldExecutor(params: {
sessionStore: CustomWorldAgentSessionStore;
publishingService: CustomWorldAgentPublishingService;
resolveAuthorDisplayName?: ((userId: string) => Promise<string>) | null;
}): CustomWorldAgentActionExecutor<'publish_world'> {
return async ({ userId, sessionId, operationId }) => {
const updateOperation = createOperationUpdater({
sessionStore: params.sessionStore,
userId,
sessionId,
operationId,
});
try {
await updateOperation({
status: 'running',
phaseLabel: '执行发布校验',
phaseDetail: '正在检查角色资产、场景图和主线草稿是否满足发布门槛。',
progress: 28,
});
const latestSession = await getRequiredSession({
sessionStore: params.sessionStore,
userId,
sessionId,
});
try {
params.publishingService.buildPublishReadiness({
sessionId,
draftProfile: latestSession.draftProfile,
qualityFindings: latestSession.qualityFindings,
});
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'publish world failed';
await params.sessionStore.appendMessage(
userId,
sessionId,
{
id: `message-${Date.now().toString(36)}-publish-warning`,
role: 'assistant',
kind: 'warning',
text: buildGateFailureMessage(errorMessage),
createdAt: new Date().toISOString(),
relatedOperationId: operationId,
},
);
throw error;
}
await updateOperation({
phaseLabel: '发布正式世界',
phaseDetail: '正在把当前草稿编译成正式世界档案并写入作品库。',
progress: 68,
});
const authorDisplayName = params.resolveAuthorDisplayName
? await params.resolveAuthorDisplayName(userId)
: '玩家';
const publishResult = await params.publishingService.publishSessionDraft({
userId,
authorDisplayName: authorDisplayName.trim() || '玩家',
sessionId,
draftProfile:
(latestSession.draftProfile ?? {}) as Record<string, unknown>,
qualityFindings: latestSession.qualityFindings,
});
const worldName = resolvePublishedWorldName(publishResult.publishedProfile);
const publishedQualityFindings = latestSession.qualityFindings.filter(
(entry) => entry.severity !== 'blocker',
);
const publishedState = {
stage: 'published' as const,
qualityFindings: publishedQualityFindings,
};
await params.sessionStore.replaceDerivedState(
userId,
sessionId,
publishedState,
);
await params.sessionStore.appendCheckpoint(userId, sessionId, {
label: `发布世界 ${worldName}`,
snapshot: buildCheckpointSnapshot(latestSession, publishedState),
});
await params.sessionStore.appendMessage(
userId,
sessionId,
buildActionResultMessage({
relatedOperationId: operationId,
text:
publishedQualityFindings.length > 0
? `世界「${worldName}」已发布,并保留 ${publishedQualityFindings.length} 条 warning 供后续继续优化。`
: `世界「${worldName}」已正式发布,可以进入作品库与世界入口。`,
}),
);
await updateOperation({
status: 'completed',
phaseLabel: '世界已发布',
phaseDetail: `正式世界档案已写入作品库:${publishResult.profileId}`,
progress: 100,
error: null,
});
} catch (error) {
await updateOperation({
status: 'failed',
phaseLabel: '发布失败',
phaseDetail: '当前世界还没有通过发布校验或写入作品库失败。',
progress: 100,
error: error instanceof Error ? error.message : 'publish world failed',
});
}
};
}

View File

@@ -0,0 +1,95 @@
import type { CustomWorldAgentActionExecutor } from './types.js';
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
import { buildActionResultMessage } from './helpers.js';
import {
createOperationUpdater,
getRequiredSession,
} from './executorShared.js';
export function createRevertCheckpointExecutor(params: {
sessionStore: CustomWorldAgentSessionStore;
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
}): CustomWorldAgentActionExecutor<'revert_checkpoint'> {
return async ({ userId, sessionId, operationId, payload }) => {
const updateOperation = createOperationUpdater({
sessionStore: params.sessionStore,
userId,
sessionId,
operationId,
});
try {
await updateOperation({
status: 'running',
phaseLabel: '恢复历史检查点',
phaseDetail: '正在把指定检查点的草稿状态恢复到当前会话。',
progress: 36,
});
const latestSession = await getRequiredSession({
sessionStore: params.sessionStore,
userId,
sessionId,
});
const checkpoint = latestSession.checkpoints.find(
(entry) => entry.checkpointId === payload.checkpointId,
);
if (!checkpoint?.snapshot) {
throw new Error('目标检查点不存在,或当前检查点还没有可恢复快照。');
}
await params.sessionStore.restoreCheckpoint(
userId,
sessionId,
payload.checkpointId,
);
const restoredSession = await getRequiredSession({
sessionStore: params.sessionStore,
userId,
sessionId,
});
const nextState = params.snapshotBuilder.buildRefiningState({
previousStage: restoredSession.stage,
nextStage:
restoredSession.stage === 'visual_refining' ||
restoredSession.stage === 'long_tail_review' ||
restoredSession.stage === 'ready_to_publish'
? restoredSession.stage
: 'object_refining',
draftProfile:
(restoredSession.draftProfile ?? {}) as Record<string, unknown>,
focusCardId: restoredSession.focusCardId,
});
await params.sessionStore.replaceDerivedState(userId, sessionId, nextState);
await params.sessionStore.appendMessage(
userId,
sessionId,
buildActionResultMessage({
relatedOperationId: operationId,
text: `已恢复到检查点「${checkpoint.label}」,当前草稿和卡片摘要已经回滚到对应版本。`,
}),
);
await updateOperation({
status: 'completed',
phaseLabel: '检查点已恢复',
phaseDetail: `已恢复到「${checkpoint.label}」。`,
progress: 100,
error: null,
});
} catch (error) {
await updateOperation({
status: 'failed',
phaseLabel: '恢复检查点失败',
phaseDetail: '这一轮没有成功恢复历史检查点。',
progress: 100,
error:
error instanceof Error
? error.message
: 'revert checkpoint failed',
});
}
};
}

View File

@@ -0,0 +1,87 @@
import type { CustomWorldAgentActionExecutor } from './types.js';
import type { CustomWorldAgentResultSyncService } from '../customWorldAgentResultSyncService.js';
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
import { buildActionResultMessage } from './helpers.js';
import {
buildCheckpointSnapshot,
createOperationUpdater,
getRequiredSession,
} from './executorShared.js';
export function createSyncResultProfileExecutor(params: {
sessionStore: CustomWorldAgentSessionStore;
resultSyncService: CustomWorldAgentResultSyncService;
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
}): CustomWorldAgentActionExecutor<'sync_result_profile'> {
return async ({ userId, sessionId, operationId, payload }) => {
const updateOperation = createOperationUpdater({
sessionStore: params.sessionStore,
userId,
sessionId,
operationId,
});
try {
await updateOperation({
status: 'running',
phaseLabel: '同步结果页快照',
phaseDetail: '正在把结果页里的最新世界结构写回当前草稿。',
progress: 36,
});
const latestSession = await getRequiredSession({
sessionStore: params.sessionStore,
userId,
sessionId,
});
const nextDraftProfile =
params.resultSyncService.syncResultProfileIntoDraftProfile({
currentDraftProfile: latestSession.draftProfile,
resultProfile: payload.profile as never,
});
await updateOperation({
phaseLabel: '重编译草稿摘要',
phaseDetail: '正在刷新草稿卡摘要、资产覆盖和结果页恢复快照。',
progress: 72,
});
const nextState = params.snapshotBuilder.buildRefiningState({
previousStage: latestSession.stage,
draftProfile: nextDraftProfile,
});
await params.sessionStore.replaceDerivedState(userId, sessionId, nextState);
await params.sessionStore.appendCheckpoint(userId, sessionId, {
label: '同步结果页编辑',
snapshot: buildCheckpointSnapshot(latestSession, nextState),
});
await params.sessionStore.appendMessage(
userId,
sessionId,
buildActionResultMessage({
relatedOperationId: operationId,
text: '结果页里的最新世界结构已经同步回当前草稿。',
}),
);
await updateOperation({
status: 'completed',
phaseLabel: '结果页快照已同步',
phaseDetail: '当前结果页编辑内容已经并回 Agent 草稿主链。',
progress: 100,
error: null,
});
} catch (error) {
await updateOperation({
status: 'failed',
phaseLabel: '结果页同步失败',
phaseDetail: '这一轮结果页编辑没有成功写回当前草稿。',
progress: 100,
error:
error instanceof Error ? error.message : 'sync result profile failed',
});
}
};
}

View File

@@ -0,0 +1,97 @@
import { resolveRoleAssetStatusLabel } from '../customWorldAgentRoleAssetStateService.js';
import type { CustomWorldAgentActionExecutor } from './types.js';
import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js';
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
import {
buildActionResultMessage,
buildRoleAssetSyncResultText,
} from './helpers.js';
import {
buildCheckpointSnapshot,
createOperationUpdater,
getRequiredSession,
} from './executorShared.js';
export function createSyncRoleAssetsExecutor(params: {
sessionStore: CustomWorldAgentSessionStore;
assetBridgeService: CustomWorldAgentAssetBridgeService;
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
}): CustomWorldAgentActionExecutor<'sync_role_assets'> {
return async ({ userId, sessionId, operationId, payload }) => {
const updateOperation = createOperationUpdater({
sessionStore: params.sessionStore,
userId,
sessionId,
operationId,
});
try {
await updateOperation({
status: 'running',
phaseLabel: '同步角色资产',
phaseDetail: '正在把主图与动作结果写回当前世界草稿。',
progress: 36,
});
const latestSession = await getRequiredSession({
sessionStore: params.sessionStore,
userId,
sessionId,
});
const syncResult = params.assetBridgeService.applyRoleAssetPublishResult(
latestSession.draftProfile,
payload,
);
await updateOperation({
phaseLabel: '刷新角色卡摘要',
phaseDetail: '正在同步更新角色卡状态与资产覆盖。',
progress: 72,
});
const nextState = params.snapshotBuilder.buildRefiningState({
previousStage: latestSession.stage,
nextStage: 'visual_refining',
draftProfile: syncResult.draftProfile,
focusCardId: payload.roleId,
});
await params.sessionStore.replaceDerivedState(userId, sessionId, nextState);
await params.sessionStore.appendCheckpoint(userId, sessionId, {
label: `同步角色资产 ${syncResult.updatedAssetSummary.roleName}`,
snapshot: buildCheckpointSnapshot(latestSession, nextState),
});
await params.sessionStore.appendMessage(
userId,
sessionId,
buildActionResultMessage({
relatedOperationId: operationId,
text: buildRoleAssetSyncResultText({
roleName: syncResult.updatedAssetSummary.roleName,
assetStatusLabel: resolveRoleAssetStatusLabel(
syncResult.updatedAssetSummary.status,
),
}),
}),
);
await updateOperation({
status: 'completed',
phaseLabel: '角色资产已同步',
phaseDetail: `${syncResult.updatedAssetSummary.roleName}」的资产状态已更新为${resolveRoleAssetStatusLabel(syncResult.updatedAssetSummary.status)}`,
progress: 100,
error: null,
});
} catch (error) {
await updateOperation({
status: 'failed',
phaseLabel: '角色资产同步失败',
phaseDetail: '这一轮没有成功把角色资产写回草稿。',
progress: 100,
error:
error instanceof Error ? error.message : 'sync role assets failed',
});
}
};
}

View File

@@ -0,0 +1,88 @@
import type { CustomWorldAgentActionExecutor } from './types.js';
import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js';
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
import { buildActionResultMessage } from './helpers.js';
import {
buildCheckpointSnapshot,
createOperationUpdater,
getRequiredSession,
} from './executorShared.js';
export function createSyncSceneAssetsExecutor(params: {
sessionStore: CustomWorldAgentSessionStore;
assetBridgeService: CustomWorldAgentAssetBridgeService;
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
}): CustomWorldAgentActionExecutor<'sync_scene_assets'> {
return async ({ userId, sessionId, operationId, payload }) => {
const updateOperation = createOperationUpdater({
sessionStore: params.sessionStore,
userId,
sessionId,
operationId,
});
try {
await updateOperation({
status: 'running',
phaseLabel: '同步场景资产',
phaseDetail: '正在把营地/地点场景图写回当前世界草稿。',
progress: 38,
});
const latestSession = await getRequiredSession({
sessionStore: params.sessionStore,
userId,
sessionId,
});
const syncResult = params.assetBridgeService.applySceneAssetPublishResult(
latestSession.draftProfile,
payload,
);
await updateOperation({
phaseLabel: '刷新场景卡摘要',
phaseDetail: '正在更新地点卡、幕背景摘要和场景资产覆盖率。',
progress: 72,
});
const nextState = params.snapshotBuilder.buildRefiningState({
previousStage: latestSession.stage,
nextStage: 'visual_refining',
draftProfile: syncResult.draftProfile,
focusCardId: payload.sceneId,
});
await params.sessionStore.replaceDerivedState(userId, sessionId, nextState);
await params.sessionStore.appendCheckpoint(userId, sessionId, {
label: `同步场景资产 ${String(syncResult.updatedScene.name ?? payload.sceneId)}`,
snapshot: buildCheckpointSnapshot(latestSession, nextState),
});
await params.sessionStore.appendMessage(
userId,
sessionId,
buildActionResultMessage({
relatedOperationId: operationId,
text: `已把「${String(syncResult.updatedScene.name ?? '当前场景')}」的场景图写回草稿,并同步刷新地点卡与幕背景状态。`,
}),
);
await updateOperation({
status: 'completed',
phaseLabel: '场景资产已同步',
phaseDetail: `${String(syncResult.updatedScene.name ?? '当前场景')}」的场景图已经进入当前草稿。`,
progress: 100,
error: null,
});
} catch (error) {
await updateOperation({
status: 'failed',
phaseLabel: '场景资产同步失败',
phaseDetail: '这一轮没有成功把场景图写回当前草稿。',
progress: 100,
error:
error instanceof Error ? error.message : 'sync scene assets failed',
});
}
};
}

View File

@@ -0,0 +1,29 @@
import type { CustomWorldAgentActionRequest } from '../../../../packages/shared/src/contracts/customWorldAgent.js';
export type CustomWorldAgentActionPayload<
K extends CustomWorldAgentActionRequest['action'],
> = Extract<CustomWorldAgentActionRequest, { action: K }>;
export type CustomWorldAgentActionExecutor<
K extends CustomWorldAgentActionRequest['action'],
> = (params: {
userId: string;
sessionId: string;
operationId: string;
payload: CustomWorldAgentActionPayload<K>;
}) => Promise<void>;
export type CustomWorldAgentActionExecutorMap = {
draft_foundation: CustomWorldAgentActionExecutor<'draft_foundation'>;
update_draft_card: CustomWorldAgentActionExecutor<'update_draft_card'>;
sync_result_profile: CustomWorldAgentActionExecutor<'sync_result_profile'>;
generate_characters: CustomWorldAgentActionExecutor<'generate_characters'>;
generate_landmarks: CustomWorldAgentActionExecutor<'generate_landmarks'>;
generate_role_assets: CustomWorldAgentActionExecutor<'generate_role_assets'>;
sync_role_assets: CustomWorldAgentActionExecutor<'sync_role_assets'>;
generate_scene_assets: CustomWorldAgentActionExecutor<'generate_scene_assets'>;
sync_scene_assets: CustomWorldAgentActionExecutor<'sync_scene_assets'>;
expand_long_tail: CustomWorldAgentActionExecutor<'expand_long_tail'>;
publish_world: CustomWorldAgentActionExecutor<'publish_world'>;
revert_checkpoint: CustomWorldAgentActionExecutor<'revert_checkpoint'>;
};

View File

@@ -0,0 +1,111 @@
import { updateDraftCardSections } from '../customWorldAgentDraftEditService.js';
import type { CustomWorldAgentActionExecutor } from './types.js';
import type { CustomWorldAgentChangeSummaryService } from '../customWorldAgentChangeSummaryService.js';
import type { CustomWorldAgentDraftCompiler } from '../customWorldAgentDraftCompiler.js';
import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js';
import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js';
import { buildActionResultMessage } from './helpers.js';
import {
buildCheckpointSnapshot,
createOperationUpdater,
getRequiredSession,
} from './executorShared.js';
export function createUpdateDraftCardExecutor(params: {
sessionStore: CustomWorldAgentSessionStore;
draftCompiler: CustomWorldAgentDraftCompiler;
changeSummaryService: CustomWorldAgentChangeSummaryService;
snapshotBuilder: CustomWorldAgentSnapshotBuilder;
}): CustomWorldAgentActionExecutor<'update_draft_card'> {
return async ({ userId, sessionId, operationId, payload }) => {
const updateOperation = createOperationUpdater({
sessionStore: params.sessionStore,
userId,
sessionId,
operationId,
});
try {
await updateOperation({
status: 'running',
phaseLabel: '写回草稿设定',
phaseDetail: '正在把这次编辑内容写回当前世界底稿。',
progress: 34,
});
const latestSession = await getRequiredSession({
sessionStore: params.sessionStore,
userId,
sessionId,
});
const nextDraftProfile = updateDraftCardSections({
draftProfile: (latestSession.draftProfile ?? {}) as Record<
string,
unknown
>,
cardId: payload.cardId,
sections: payload.sections,
});
await updateOperation({
phaseLabel: '重编译草稿卡',
phaseDetail: '正在同步更新草稿摘要和详情内容。',
progress: 72,
});
const nextState = params.snapshotBuilder.buildRefiningState({
previousStage: latestSession.stage,
draftProfile: nextDraftProfile,
focusCardId: payload.cardId,
});
const updatedDetail = params.draftCompiler.getDraftCardDetail(
nextDraftProfile,
payload.cardId,
);
const changedSectionIds = new Set(
payload.sections
.map((section) => section.sectionId.trim())
.filter(Boolean),
);
await params.sessionStore.replaceDerivedState(userId, sessionId, nextState);
await params.sessionStore.appendCheckpoint(userId, sessionId, {
label: `编辑 ${updatedDetail?.title || '草稿卡'}`,
snapshot: buildCheckpointSnapshot(latestSession, nextState),
});
await params.sessionStore.appendMessage(
userId,
sessionId,
buildActionResultMessage({
relatedOperationId: operationId,
text: params.changeSummaryService.buildSummary({
action: 'update_draft_card',
cardId: payload.cardId,
changedLabels:
updatedDetail?.sections
.filter((section) => changedSectionIds.has(section.id))
.map((section) => section.label) ?? [],
draftProfile: nextDraftProfile,
}),
}),
);
await updateOperation({
status: 'completed',
phaseLabel: '草稿设定已保存',
phaseDetail: `${updatedDetail?.title || '当前卡片'}」的设定已经同步更新。`,
progress: 100,
error: null,
});
} catch (error) {
await updateOperation({
status: 'failed',
phaseLabel: '保存失败',
phaseDetail: '这次草稿编辑没有成功写回到底稿。',
progress: 100,
error:
error instanceof Error ? error.message : 'update draft card failed',
});
}
};
}

View File

@@ -0,0 +1,260 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CustomWorldAgentActionExecutorMap } from './customWorldAgentActionExecutors/index.js';
import { CustomWorldAgentActionRegistry } from './customWorldAgentActionRegistry.js';
import { createRpgAgentSessionFixture } from '../../../packages/shared/src/contracts/rpgCreationFixtures.js';
function createExecutorLog() {
const calls: Array<{
action: keyof CustomWorldAgentActionExecutorMap;
payload: unknown;
userId: string;
sessionId: string;
operationId: string;
}> = [];
const createExecutor = <K extends keyof CustomWorldAgentActionExecutorMap>(
action: K,
): CustomWorldAgentActionExecutorMap[K] => {
return (async (params) => {
calls.push({
action,
payload: params.payload,
userId: params.userId,
sessionId: params.sessionId,
operationId: params.operationId,
});
}) as CustomWorldAgentActionExecutorMap[K];
};
return {
calls,
executors: {
draft_foundation: createExecutor('draft_foundation'),
update_draft_card: createExecutor('update_draft_card'),
sync_result_profile: createExecutor('sync_result_profile'),
generate_characters: createExecutor('generate_characters'),
generate_landmarks: createExecutor('generate_landmarks'),
generate_role_assets: createExecutor('generate_role_assets'),
sync_role_assets: createExecutor('sync_role_assets'),
generate_scene_assets: createExecutor('generate_scene_assets'),
sync_scene_assets: createExecutor('sync_scene_assets'),
expand_long_tail: createExecutor('expand_long_tail'),
publish_world: createExecutor('publish_world'),
revert_checkpoint: createExecutor('revert_checkpoint'),
} satisfies CustomWorldAgentActionExecutorMap,
};
}
function createSessionRecord(overrides: Partial<Record<string, unknown>> = {}) {
const session = createRpgAgentSessionFixture();
return {
...JSON.parse(JSON.stringify(session)),
userId: 'fixture-user',
seedText: '被海雾吞没的旧航路群岛',
operations: [],
checkpoints: [],
createdAt: session.updatedAt,
updatedAt: session.updatedAt,
...overrides,
};
}
test('action registry exposes supported actions with stage-aware enablement and disabled reasons', () => {
const { executors } = createExecutorLog();
const registry = new CustomWorldAgentActionRegistry(executors);
const session = createSessionRecord({
stage: 'foundation_review',
progressPercent: 80,
});
const supportedActions = registry.buildSupportedActions(session as never);
const draftFoundation = supportedActions.find(
(entry) => entry.action === 'draft_foundation',
);
const syncResultProfile = supportedActions.find(
(entry) => entry.action === 'sync_result_profile',
);
const publishWorld = supportedActions.find(
(entry) => entry.action === 'publish_world',
);
const expandLongTail = supportedActions.find(
(entry) => entry.action === 'expand_long_tail',
);
const revertCheckpoint = supportedActions.find(
(entry) => entry.action === 'revert_checkpoint',
);
assert.equal(draftFoundation?.enabled, false);
assert.match(draftFoundation?.reason ?? '', /progressPercent >= 100/u);
assert.equal(syncResultProfile?.enabled, false);
assert.match(
syncResultProfile?.reason ?? '',
/object_refining or visual_refining/u,
);
assert.equal(publishWorld?.enabled, false);
assert.match(
publishWorld?.reason ?? '',
/object_refining, visual_refining, long_tail_review or ready_to_publish/u,
);
assert.equal(expandLongTail?.enabled, false);
assert.match(
expandLongTail?.reason ?? '',
/object_refining, visual_refining, long_tail_review or ready_to_publish/u,
);
assert.equal(revertCheckpoint?.enabled, false);
assert.match(
revertCheckpoint?.reason ?? '',
/requires at least one restorable checkpoint snapshot/u,
);
});
test('action registry enables long-tail and publish actions in late stages, and exposes revert when restorable checkpoint exists', () => {
const { executors } = createExecutorLog();
const registry = new CustomWorldAgentActionRegistry(executors);
const session = createSessionRecord({
stage: 'ready_to_publish',
checkpoints: [
{
checkpointId: 'checkpoint-1',
createdAt: '2026-04-21T12:00:00.000Z',
label: '可回滚版本',
snapshot: {
currentTurn: 2,
anchorContent: createSessionRecord().anchorContent,
progressPercent: 100,
lastAssistantReply: '已生成草稿。',
stage: 'object_refining',
focusCardId: 'world-foundation',
creatorIntent: {},
creatorIntentReadiness: {
isReady: true,
completedKeys: [],
missingKeys: [],
},
anchorPack: {},
lockState: {},
draftProfile: createSessionRecord().draftProfile,
pendingClarifications: [],
suggestedActions: [],
recommendedReplies: [],
draftCards: createSessionRecord().draftCards,
qualityFindings: [],
assetCoverage: createSessionRecord().assetCoverage,
},
},
],
});
const supportedActions = registry.buildSupportedActions(session as never);
assert.equal(
supportedActions.find((entry) => entry.action === 'expand_long_tail')?.enabled,
true,
);
assert.equal(
supportedActions.find((entry) => entry.action === 'publish_world')?.enabled,
true,
);
assert.equal(
supportedActions.find((entry) => entry.action === 'revert_checkpoint')?.enabled,
true,
);
});
test('action registry validates sync_scene_assets required payload and dispatches scene action executors', async () => {
const { calls, executors } = createExecutorLog();
const registry = new CustomWorldAgentActionRegistry(executors);
const session = createSessionRecord({
stage: 'visual_refining',
});
assert.throws(
() =>
registry.prepareExecution(session as never, {
action: 'sync_scene_assets',
sceneId: 'camp-home',
sceneKind: 'camp',
imageSrc: '',
generatedSceneAssetId: 'scene-asset-1',
}),
/imageSrc and generatedSceneAssetId/u,
);
const prepared = registry.prepareExecution(session as never, {
action: 'generate_scene_assets',
sceneIds: ['camp-home'],
});
assert.equal(prepared.operationType, 'generate_scene_assets');
await prepared.execute({
userId: 'fixture-user',
sessionId: 'fixture-session',
operationId: 'operation-scene-1',
});
assert.equal(calls.at(-1)?.action, 'generate_scene_assets');
});
test('action registry normalizes sync_result_profile payload before dispatching executor', async () => {
const { calls, executors } = createExecutorLog();
const registry = new CustomWorldAgentActionRegistry(executors);
const session = createSessionRecord({
stage: 'object_refining',
});
const prepared = registry.prepareExecution(session as never, {
action: 'sync_result_profile',
profile: {
id: 'profile-1',
settingText: '潮雾列岛',
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '结果页确认版。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清真相。',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会'],
coreConflicts: ['争夺旧航路控制权'],
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [],
generationMode: 'full',
generationStatus: 'complete',
},
});
assert.equal(prepared.operationType, 'sync_result_profile');
await prepared.execute({
userId: 'fixture-user',
sessionId: 'fixture-session',
operationId: 'operation-1',
});
assert.equal(calls.length, 1);
assert.equal(calls[0]?.action, 'sync_result_profile');
assert.equal(
(calls[0]?.payload as { profile?: { name?: string } })?.profile?.name,
'潮雾列岛',
);
});
test('action registry rejects invalid generate_role_assets payload with unit-level validation', () => {
const { executors } = createExecutorLog();
const registry = new CustomWorldAgentActionRegistry(executors);
const session = createSessionRecord({
stage: 'object_refining',
});
assert.throws(
() =>
registry.prepareExecution(session as never, {
action: 'generate_role_assets',
roleIds: ['playable-1', 'story-1'],
}),
/exactly one roleId/u,
);
});

View File

@@ -0,0 +1,403 @@
import type {
CustomWorldAgentActionRequest,
CustomWorldAgentOperationRecord,
CustomWorldSupportedAction,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import { badRequest } from '../errors.js';
import { normalizeCustomWorldProfile } from '../modules/custom-world/runtimeProfile.js';
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
import type {
CustomWorldAgentActionExecutorMap,
CustomWorldAgentActionPayload,
} from './customWorldAgentActionExecutors/index.js';
import type { CustomWorldAgentSessionRecord } from './customWorldAgentSessionStore.js';
type EnabledAction = keyof CustomWorldAgentActionExecutorMap;
type EnabledDescriptor<K extends EnabledAction> = {
operationType: CustomWorldAgentOperationRecord['type'];
normalizePayload?: (
payload: CustomWorldAgentActionPayload<K>,
) => CustomWorldAgentActionPayload<K>;
validate?: (
session: CustomWorldAgentSessionRecord,
payload: CustomWorldAgentActionPayload<K>,
) => void;
execute: CustomWorldAgentActionExecutorMap[K];
};
type DisabledAction = Exclude<CustomWorldAgentActionRequest['action'], EnabledAction>;
type DisabledDescriptor = {
disabledReason: string;
};
type ActionCapabilityState = {
enabled: boolean;
reason?: string;
};
function assertDraftRefiningActionAvailable(
session: CustomWorldAgentSessionRecord,
action: string,
) {
if (
session.stage !== 'object_refining' &&
session.stage !== 'visual_refining'
) {
throw badRequest(
`${action} is only available during object_refining or visual_refining`,
);
}
const hasDraftFoundation = Boolean(
normalizeFoundationDraftProfile(session.draftProfile) &&
session.draftCards.length > 0,
);
if (!hasDraftFoundation) {
throw badRequest(`${action} requires an existing draft foundation`);
}
}
function assertLongTailActionAvailable(
session: CustomWorldAgentSessionRecord,
action: string,
) {
if (
session.stage !== 'object_refining' &&
session.stage !== 'visual_refining' &&
session.stage !== 'long_tail_review' &&
session.stage !== 'ready_to_publish'
) {
throw badRequest(
`${action} is only available during object_refining, visual_refining, long_tail_review or ready_to_publish`,
);
}
}
function assertPublishActionAvailable(
session: CustomWorldAgentSessionRecord,
action: string,
) {
assertLongTailActionAvailable(session, action);
if (!normalizeFoundationDraftProfile(session.draftProfile)) {
throw badRequest(`${action} requires an existing draft foundation`);
}
}
export type PreparedCustomWorldAgentActionExecution = {
operationType: CustomWorldAgentOperationRecord['type'];
execute: (params: {
userId: string;
sessionId: string;
operationId: string;
}) => Promise<void>;
};
export class CustomWorldAgentActionRegistry {
private readonly descriptors: Record<
CustomWorldAgentActionRequest['action'],
EnabledDescriptor<EnabledAction> | DisabledDescriptor
>;
constructor(executors: CustomWorldAgentActionExecutorMap) {
this.descriptors = {
draft_foundation: {
operationType: 'draft_foundation',
validate: (session) => {
if (session.progressPercent < 100) {
throw badRequest('draft_foundation requires progressPercent >= 100');
}
},
execute: executors.draft_foundation,
},
update_draft_card: {
operationType: 'update_draft_card',
validate: (session, payload) => {
assertDraftRefiningActionAvailable(session, payload.action);
if (!payload.cardId.trim()) {
throw badRequest('update_draft_card requires cardId');
}
if (!Array.isArray(payload.sections) || payload.sections.length === 0) {
throw badRequest('update_draft_card requires sections');
}
},
execute: executors.update_draft_card,
},
sync_result_profile: {
operationType: 'sync_result_profile',
normalizePayload: (payload) => {
const normalizedProfile = normalizeCustomWorldProfile(payload.profile, '');
if (!normalizedProfile) {
throw badRequest('sync_result_profile requires a valid profile');
}
return {
...payload,
profile: normalizedProfile as unknown as Record<string, unknown>,
};
},
validate: (session, payload) => {
assertDraftRefiningActionAvailable(session, payload.action);
},
execute: executors.sync_result_profile,
},
generate_characters: {
operationType: 'generate_characters',
validate: (session, payload) => {
assertDraftRefiningActionAvailable(session, payload.action);
if (payload.count < 1 || payload.count > 3) {
throw badRequest(
'generate_characters count must be between 1 and 3',
);
}
},
execute: executors.generate_characters,
},
generate_landmarks: {
operationType: 'generate_landmarks',
validate: (session, payload) => {
assertDraftRefiningActionAvailable(session, payload.action);
if (payload.count < 1 || payload.count > 3) {
throw badRequest(
'generate_landmarks count must be between 1 and 3',
);
}
},
execute: executors.generate_landmarks,
},
generate_role_assets: {
operationType: 'generate_role_assets',
validate: (session, payload) => {
assertDraftRefiningActionAvailable(session, payload.action);
if (!Array.isArray(payload.roleIds) || payload.roleIds.length !== 1) {
throw badRequest(
'generate_role_assets currently requires exactly one roleId',
);
}
},
execute: executors.generate_role_assets,
},
sync_role_assets: {
operationType: 'sync_role_assets',
validate: (session, payload) => {
assertDraftRefiningActionAvailable(session, payload.action);
if (!payload.roleId.trim()) {
throw badRequest('sync_role_assets requires roleId');
}
if (
!payload.portraitPath.trim() ||
!payload.generatedVisualAssetId.trim()
) {
throw badRequest(
'sync_role_assets requires portraitPath and generatedVisualAssetId',
);
}
},
execute: executors.sync_role_assets,
},
generate_scene_assets: {
operationType: 'generate_scene_assets',
validate: (session, payload) => {
assertDraftRefiningActionAvailable(session, payload.action);
if (!Array.isArray(payload.sceneIds) || payload.sceneIds.length !== 1) {
throw badRequest(
'generate_scene_assets currently requires exactly one sceneId',
);
}
},
execute: executors.generate_scene_assets,
},
sync_scene_assets: {
operationType: 'sync_scene_assets',
validate: (session, payload) => {
assertDraftRefiningActionAvailable(session, payload.action);
if (!payload.sceneId.trim()) {
throw badRequest('sync_scene_assets requires sceneId');
}
if (!payload.imageSrc.trim() || !payload.generatedSceneAssetId.trim()) {
throw badRequest(
'sync_scene_assets requires imageSrc and generatedSceneAssetId',
);
}
},
execute: executors.sync_scene_assets,
},
expand_long_tail: {
operationType: 'expand_long_tail',
validate: (session, payload) => {
assertLongTailActionAvailable(session, payload.action);
if (!normalizeFoundationDraftProfile(session.draftProfile)) {
throw badRequest('expand_long_tail requires an existing draft foundation');
}
},
execute: executors.expand_long_tail,
},
publish_world: {
operationType: 'publish_world',
validate: (session, payload) => {
assertPublishActionAvailable(session, payload.action);
},
execute: executors.publish_world,
},
revert_checkpoint: {
operationType: 'revert_checkpoint',
validate: (session, payload) => {
assertLongTailActionAvailable(session, payload.action);
if (!payload.checkpointId.trim()) {
throw badRequest('revert_checkpoint requires checkpointId');
}
const checkpoint = session.checkpoints.find(
(entry) => entry.checkpointId === payload.checkpointId,
);
if (!checkpoint) {
throw badRequest('revert_checkpoint target checkpoint does not exist');
}
if (!checkpoint.snapshot) {
throw badRequest(
'revert_checkpoint target checkpoint does not contain a restorable snapshot',
);
}
},
execute: executors.revert_checkpoint,
},
};
}
// orchestrator 只关心“拿到一个已校验的动作执行计划”,不再自己维护所有 action 分支。
prepareExecution(
session: CustomWorldAgentSessionRecord,
payload: CustomWorldAgentActionRequest,
): PreparedCustomWorldAgentActionExecution {
const descriptor = this.descriptors[payload.action];
if ('disabledReason' in descriptor) {
throw badRequest(descriptor.disabledReason);
}
const normalizedPayload = descriptor.normalizePayload
? descriptor.normalizePayload(payload as never)
: payload;
descriptor.validate?.(session, normalizedPayload as never);
return {
operationType: descriptor.operationType,
execute: ({ userId, sessionId, operationId }) =>
descriptor.execute({
userId,
sessionId,
operationId,
payload: normalizedPayload as never,
}),
};
}
buildSupportedActions(
session: CustomWorldAgentSessionRecord,
): CustomWorldSupportedAction[] {
return (
Object.entries(this.descriptors) as Array<
[
CustomWorldAgentActionRequest['action'],
EnabledDescriptor<EnabledAction> | DisabledDescriptor,
]
>
).map(([action, descriptor]) => {
const capability = this.resolveCapabilityState(session, action, descriptor);
return {
action,
enabled: capability.enabled,
reason: capability.reason ?? null,
} satisfies CustomWorldSupportedAction;
});
}
private resolveCapabilityState(
session: CustomWorldAgentSessionRecord,
action: CustomWorldAgentActionRequest['action'],
descriptor: EnabledDescriptor<EnabledAction> | DisabledDescriptor,
): ActionCapabilityState {
if ('disabledReason' in descriptor) {
return {
enabled: false,
reason: descriptor.disabledReason,
};
}
if (action === 'draft_foundation') {
return session.progressPercent >= 100
? { enabled: true }
: {
enabled: false,
reason: 'draft_foundation requires progressPercent >= 100',
};
}
if (
action === 'update_draft_card' ||
action === 'sync_result_profile' ||
action === 'generate_characters' ||
action === 'generate_landmarks' ||
action === 'generate_role_assets' ||
action === 'sync_role_assets' ||
action === 'generate_scene_assets' ||
action === 'sync_scene_assets'
) {
try {
assertDraftRefiningActionAvailable(session, action);
return { enabled: true };
} catch (error) {
return {
enabled: false,
reason: error instanceof Error ? error.message : 'action unavailable',
};
}
}
if (action === 'expand_long_tail') {
try {
assertLongTailActionAvailable(session, action);
return { enabled: true };
} catch (error) {
return {
enabled: false,
reason: error instanceof Error ? error.message : 'action unavailable',
};
}
}
if (action === 'publish_world') {
try {
assertPublishActionAvailable(session, action);
return { enabled: true };
} catch (error) {
return {
enabled: false,
reason: error instanceof Error ? error.message : 'action unavailable',
};
}
}
if (action === 'revert_checkpoint') {
const restorableCheckpoint = session.checkpoints.find(
(entry) => Boolean(entry.snapshot),
);
if (!restorableCheckpoint) {
return {
enabled: false,
reason: 'revert_checkpoint requires at least one restorable checkpoint snapshot',
};
}
try {
assertLongTailActionAvailable(session, action);
return { enabled: true };
} catch (error) {
return {
enabled: false,
reason: error instanceof Error ? error.message : 'action unavailable',
};
}
}
return { enabled: true };
}
}

View File

@@ -1,6 +1,10 @@
import type { CustomWorldRoleAssetSummary } from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type {
CustomWorldRoleAssetSummary,
CustomWorldSceneAssetSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import {
getRoleAssetSummaryById,
rebuildRoleAssetCoverage,
mergeRoleAssetIntoDraftProfile,
} from './customWorldAgentRoleAssetStateService.js';
@@ -31,6 +35,17 @@ type SyncRoleAssetsPayload = {
animationMap?: Record<string, unknown> | null;
};
type SceneKind = 'camp' | 'landmark';
type SyncSceneAssetsPayload = {
sceneId: string;
sceneKind: SceneKind;
imageSrc: string;
generatedSceneAssetId: string;
generatedScenePrompt?: string | null;
generatedSceneModel?: string | null;
};
export type SyncRoleAssetsResult = {
roleId: string;
updatedRole: Record<string, unknown>;
@@ -38,6 +53,97 @@ export type SyncRoleAssetsResult = {
draftProfile: Record<string, unknown>;
};
export type SceneAssetStudioContext = {
sceneId: string;
sceneKind: SceneKind;
sceneName: string;
sceneDescription: string;
imageSrc: string | null;
readyActCount: number;
missingActCount: number;
};
export type SyncSceneAssetsResult = {
sceneId: string;
sceneKind: SceneKind;
updatedScene: Record<string, unknown>;
updatedAssetSummaries: CustomWorldSceneAssetSummary[];
draftProfile: Record<string, unknown>;
};
function cloneRecord<T extends Record<string, unknown>>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function toSceneDescription(scene: Record<string, unknown>, sceneKind: SceneKind) {
if (sceneKind === 'camp') {
return (
toText(scene.description) ||
toText(scene.summary) ||
toText(scene.mood)
);
}
return (
toText(scene.description) ||
toText(scene.summary) ||
toText(scene.purpose) ||
toText(scene.mood)
);
}
function findSceneActsBySceneId(
draftProfile: Record<string, unknown>,
sceneId: string,
) {
return toRecordArray(draftProfile.sceneChapters)
.filter((chapter) => toText(chapter.sceneId) === sceneId)
.flatMap((chapter) => toRecordArray(chapter.acts));
}
function updateSceneChapterActsForScene(params: {
draftProfile: Record<string, unknown>;
sceneId: string;
imageSrc: string;
generatedSceneAssetId: string;
}) {
return toRecordArray(params.draftProfile.sceneChapters).map((chapter) => {
if (toText(chapter.sceneId) !== params.sceneId) {
return chapter;
}
return {
...chapter,
acts: toRecordArray(chapter.acts).map((act) => ({
...act,
backgroundImageSrc: params.imageSrc,
backgroundAssetId: params.generatedSceneAssetId,
})),
} satisfies Record<string, unknown>;
});
}
function buildSceneAssetFallbackSummary(params: {
sceneId: string;
sceneKind: SceneKind;
updatedScene: Record<string, unknown>;
imageSrc: string;
generatedSceneAssetId: string;
}) {
return {
sceneId: params.sceneId,
sceneName:
toText(params.updatedScene.name) ||
(params.sceneKind === 'camp' ? '开局营地' : '未命名场景'),
actId: null,
actTitle: params.sceneKind === 'camp' ? '营地正式背景图' : '场景正式背景图',
imageSrc: params.imageSrc,
assetId: params.generatedSceneAssetId,
status: 'ready',
nextPointCost: 0,
} satisfies CustomWorldSceneAssetSummary;
}
export class CustomWorldAgentAssetBridgeService {
buildRoleAssetStudioContext(snapshot: unknown, roleId: string) {
const profile = toRecord(snapshot);
@@ -96,4 +202,123 @@ export class CustomWorldAgentAssetBridgeService {
draftProfile,
};
}
buildSceneAssetStudioContext(
snapshot: unknown,
sceneId: string,
sceneKind: SceneKind,
): SceneAssetStudioContext {
const profile = toRecord(snapshot);
if (!profile) {
throw new Error('当前世界草稿为空,无法打开场景资产工坊。');
}
const scene =
sceneKind === 'camp'
? toRecord(profile.camp)
: toRecordArray(profile.landmarks).find(
(item) => toText(item.id) === sceneId,
) ?? null;
if (!scene) {
throw new Error('未找到目标场景,无法进入场景资产工坊。');
}
const sceneActs = findSceneActsBySceneId(profile, sceneId);
const readyActCount = sceneActs.filter((act) =>
Boolean(toText(act.backgroundImageSrc) || toText(act.backgroundAssetId)),
).length;
return {
sceneId,
sceneKind,
sceneName:
toText(scene.name) || (sceneKind === 'camp' ? '开局营地' : '未命名场景'),
sceneDescription: toSceneDescription(scene, sceneKind),
imageSrc: toText(scene.imageSrc) || null,
readyActCount,
missingActCount: Math.max(0, sceneActs.length - readyActCount),
};
}
applySceneAssetPublishResult(
snapshot: unknown,
payload: SyncSceneAssetsPayload,
): SyncSceneAssetsResult {
const profile = toRecord(snapshot);
if (!profile) {
throw new Error('当前世界草稿为空,无法同步场景资产。');
}
const nextDraftProfile = cloneRecord(profile);
let updatedScene: Record<string, unknown> | null = null;
if (payload.sceneKind === 'camp') {
const currentCamp = toRecord(nextDraftProfile.camp);
if (!currentCamp || toText(currentCamp.id) !== payload.sceneId) {
throw new Error('目标营地不存在,无法同步场景资产。');
}
updatedScene = {
...currentCamp,
imageSrc: payload.imageSrc,
generatedSceneAssetId: payload.generatedSceneAssetId,
generatedScenePrompt: payload.generatedScenePrompt ?? null,
generatedSceneModel: payload.generatedSceneModel ?? null,
};
nextDraftProfile.camp = updatedScene;
} else {
let touched = false;
nextDraftProfile.landmarks = toRecordArray(nextDraftProfile.landmarks).map(
(item) => {
if (toText(item.id) !== payload.sceneId) {
return item;
}
touched = true;
updatedScene = {
...item,
imageSrc: payload.imageSrc,
generatedSceneAssetId: payload.generatedSceneAssetId,
generatedScenePrompt: payload.generatedScenePrompt ?? null,
generatedSceneModel: payload.generatedSceneModel ?? null,
};
return updatedScene;
},
);
if (!touched || !updatedScene) {
throw new Error('目标地点不存在,无法同步场景资产。');
}
}
nextDraftProfile.sceneChapters = updateSceneChapterActsForScene({
draftProfile: nextDraftProfile,
sceneId: payload.sceneId,
imageSrc: payload.imageSrc,
generatedSceneAssetId: payload.generatedSceneAssetId,
});
const updatedAssetSummaries = rebuildRoleAssetCoverage(
nextDraftProfile,
).sceneAssets.filter((entry) => entry.sceneId === payload.sceneId);
return {
sceneId: payload.sceneId,
sceneKind: payload.sceneKind,
updatedScene: updatedScene ?? {},
updatedAssetSummaries:
updatedAssetSummaries.length > 0
? updatedAssetSummaries
: [
buildSceneAssetFallbackSummary({
sceneId: payload.sceneId,
sceneKind: payload.sceneKind,
updatedScene: updatedScene ?? {},
imageSrc: payload.imageSrc,
generatedSceneAssetId: payload.generatedSceneAssetId,
}),
],
draftProfile: nextDraftProfile,
};
}
}

View File

@@ -87,6 +87,11 @@ function createTestConfig(testName: string): AppConfig {
mockAvatarUrl: '',
},
authSession: {
accessCookieName: 'genarrative_access_session',
accessCookieTtlSeconds: 7200,
accessCookieSecure: false,
accessCookieSameSite: 'Lax',
accessCookiePath: '/',
refreshCookieName: 'refresh_token',
refreshSessionTtlDays: 30,
refreshCookieSecure: false,

View File

@@ -377,6 +377,9 @@ function normalizeLandmark(
secret: secret || '玩家第一次抵达就会意识到它不只是背景',
dangerLevel: dangerLevel || '中',
imageSrc: toText(record.imageSrc) || null,
generatedSceneAssetId: toText(record.generatedSceneAssetId) || null,
generatedScenePrompt: toText(record.generatedScenePrompt) || null,
generatedSceneModel: toText(record.generatedSceneModel) || null,
characterIds: toStringArray(record.characterIds, 8),
threadIds: toStringArray(record.threadIds, 8),
summary:
@@ -501,6 +504,9 @@ function normalizeCamp(value: unknown): CustomWorldFoundationDraftCamp | null {
mood: dangerLevel || '克制、紧绷,但还能暂时收拢局势',
dangerLevel: dangerLevel || '克制、紧绷,但还能暂时收拢局势',
imageSrc: toText(record.imageSrc) || null,
generatedSceneAssetId: toText(record.generatedSceneAssetId) || null,
generatedScenePrompt: toText(record.generatedScenePrompt) || null,
generatedSceneModel: toText(record.generatedSceneModel) || null,
summary:
summary ||
clampText(
@@ -1060,6 +1066,9 @@ function buildLandmarkWarnings(landmark: CustomWorldFoundationDraftLandmark) {
if (landmark.threadIds.length === 0) {
warnings.push('这个地点还缺少更清楚的线程挂钩。');
}
if (!landmark.imageSrc || !landmark.generatedSceneAssetId) {
warnings.push('这个地点还没有绑定正式场景图。');
}
return warnings;
}
@@ -1163,8 +1172,12 @@ function buildSceneChapterWarnings(params: {
return warnings;
}
function buildCampWarnings() {
return [] as string[];
function buildCampWarnings(camp: CustomWorldFoundationDraftCamp) {
const warnings: string[] = [];
if (!camp.imageSrc || !camp.generatedSceneAssetId) {
warnings.push('营地还没有绑定正式场景图。');
}
return warnings;
}
function buildCharacterAssetHeadline(character: CustomWorldFoundationDraftCharacter) {
@@ -1332,12 +1345,22 @@ export class CustomWorldAgentDraftCompiler {
});
if (profile.camp) {
const campWarnings = buildCampWarnings();
const campWarnings = buildCampWarnings(profile.camp);
pushCard({
id: profile.camp.id,
kind: 'camp',
title: profile.camp.name,
subtitle: clampText(profile.camp.mood || '开局落脚处', 28),
subtitle: clampText(
[
profile.camp.mood || '开局落脚处',
profile.camp.imageSrc && profile.camp.generatedSceneAssetId
? '背景图已就绪'
: '待生成背景图',
]
.filter(Boolean)
.join(' / '),
28,
),
summary: profile.camp.summary,
linkedIds: [
...profile.landmarks.slice(0, 2).map((entry) => entry.id),
@@ -1347,14 +1370,21 @@ export class CustomWorldAgentDraftCompiler {
sections: [
buildSection('name', '营地名称', profile.camp.name),
buildSection('description', '当前定位', profile.camp.description),
buildSection(
'dangerLevel',
'危险等级',
profile.camp.dangerLevel || profile.camp.mood,
),
buildSection(
'linkedObjects',
'关联对象',
buildSection(
'dangerLevel',
'危险等级',
profile.camp.dangerLevel || profile.camp.mood,
),
buildSection(
'sceneAsset',
'场景资产',
profile.camp.imageSrc || profile.camp.generatedSceneAssetId
? '正式场景图已就绪'
: '待生成正式场景图',
),
buildSection(
'linkedObjects',
'关联对象',
[
resolveLandmarkNames(
profile.landmarks.slice(0, 2).map((entry) => entry.id),
@@ -1490,22 +1520,39 @@ export class CustomWorldAgentDraftCompiler {
profile.landmarks.forEach((landmark) => {
const warnings = buildLandmarkWarnings(landmark);
pushCard({
id: landmark.id,
kind: 'landmark',
title: landmark.name,
subtitle: clampText(landmark.purpose || landmark.mood, 28),
summary: landmark.summary,
linkedIds: [...landmark.characterIds, ...landmark.threadIds].slice(0, 8),
sections: [
buildSection('name', '地点名', landmark.name),
buildSection('purpose', '地点定位', landmark.purpose),
buildSection('mood', '场景情绪', landmark.mood),
buildSection('secret', '隐藏秘密', landmark.secret || landmark.importance),
buildSection('summary', '地点摘要', landmark.summary),
buildSection(
'characterIds',
'关联角色',
pushCard({
id: landmark.id,
kind: 'landmark',
title: landmark.name,
subtitle: clampText(
[
landmark.purpose || landmark.mood,
landmark.imageSrc && landmark.generatedSceneAssetId
? '背景图已就绪'
: '待生成背景图',
]
.filter(Boolean)
.join(' / '),
28,
),
summary: landmark.summary,
linkedIds: [...landmark.characterIds, ...landmark.threadIds].slice(0, 8),
sections: [
buildSection('name', '地点名', landmark.name),
buildSection('purpose', '地点定位', landmark.purpose),
buildSection('mood', '场景情绪', landmark.mood),
buildSection('secret', '隐藏秘密', landmark.secret || landmark.importance),
buildSection('summary', '地点摘要', landmark.summary),
buildSection(
'sceneAsset',
'场景资产',
landmark.imageSrc || landmark.generatedSceneAssetId
? '正式场景图已就绪'
: '待生成正式场景图',
),
buildSection(
'characterIds',
'关联角色',
resolveCharacterNames(landmark.characterIds),
),
buildSection(

View File

@@ -0,0 +1,324 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { UpstreamLlmClient } from './llmClient.js';
import { CustomWorldAgentFoundationDraftService } from './customWorldAgentFoundationDraftService.js';
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
function createFoundationDraftLlmClient(): UpstreamLlmClient {
let roleOutlineBatch = 0;
let landmarkSeedBatch = 0;
let landmarkNetworkBatch = 0;
let playableNarrativeBatch = 0;
let playableDossierBatch = 0;
let storyNarrativeBatch = 0;
let storyDossierBatch = 0;
return {
requestMessageContent: async (params) => {
const debugLabel = params.debugLabel ?? '';
if (debugLabel === 'agent-foundation-framework') {
return JSON.stringify({
name: '潮雾列岛',
subtitle: '盐火灯塔与失控航路',
summary: '潮雾列岛正在被假航灯和沉船商盟重新切开。',
tone: '冷峻、潮湿、悬疑',
playerGoal: '先确认谁在操盘假航灯,再决定自己站在哪一边。',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '沉船商盟'],
coreConflicts: ['守灯会与沉船商盟争夺旧航路解释权'],
camp: {
name: '雾湾前哨',
description: '玩家在盐火灯塔下方临时收束线索的地方。',
dangerLevel: 'medium',
},
playableNpcs: [],
storyNpcs: [],
landmarks: [],
});
}
if (debugLabel.startsWith('agent-foundation-playable-outline-batch-')) {
roleOutlineBatch += 1;
return JSON.stringify({
playableNpcs: [
{
name: '返灯人',
title: '失职守灯人',
role: '玩家前线身份',
description: '被迫返乡的守灯人,刚站回快要熄灭的旧灯塔。',
visualDescription: '风衣上沾着盐霜,眼底始终没有睡意。',
actionDescription: '先守住灯塔,再判断该不该相信旧友。',
sceneVisualDescription: '每次钟声响起时,他都像被过去拽住。',
initialAffinity: 18,
relationshipHooks: ['这是玩家贴近世界的第一切口'],
tags: ['玩家视角', '守灯'],
},
],
});
}
if (debugLabel.startsWith('agent-foundation-story-outline-batch-')) {
roleOutlineBatch += 1;
return JSON.stringify({
storyNpcs: [
{
name: '沈砺',
title: '旧友兼宿敌',
role: '沉船商盟引路人',
description: '他像旧友,也像最早知道假航灯秘密的人。',
visualDescription: '衣角总带着潮水味,像是刚从夜雾里走出来。',
actionDescription: '会不断试探玩家到底愿不愿意回到旧航路。',
sceneVisualDescription: '总在钟声停下后的空隙里现身。',
initialAffinity: 6,
relationshipHooks: ['和玩家共享一段无法翻篇的旧灯塔往事'],
tags: ['旧友', '宿敌'],
},
{
name: '岚珀',
title: '守灯会巡夜官',
role: '守灯会前台接口人',
description: '她负责把守灯会的怀疑与命令直接压到玩家面前。',
visualDescription: '披着潮湿斗篷,眼神总先看灯芯再看人。',
actionDescription: '要求玩家立刻证明自己还配站回灯塔。',
sceneVisualDescription: '总把巡夜灯举得很高,不给人躲闪空间。',
initialAffinity: 6,
relationshipHooks: ['会逼玩家更早站队'],
tags: ['守灯会', '巡夜'],
},
],
});
}
if (debugLabel.startsWith('agent-foundation-landmark-seed-batch-')) {
landmarkSeedBatch += 1;
return JSON.stringify({
landmarks: [
{
name: '盐火灯塔',
description: '旧灯塔正在熄灭边缘摇晃,所有人都盯着它的火。',
visualDescription: '塔身被盐霜和旧火痕反复覆盖。',
dangerLevel: 'high',
sceneNpcNames: ['沈砺', '岚珀'],
connections: [],
},
{
name: '沉船码头',
description: '假航灯把沉船和黑市都引到了这片雾港。',
visualDescription: '破碎船骨和黑帆在潮雾里若隐若现。',
dangerLevel: 'high',
sceneNpcNames: ['沈砺'],
connections: [],
},
],
});
}
if (debugLabel.startsWith('agent-foundation-landmark-network-batch-')) {
landmarkNetworkBatch += 1;
return JSON.stringify({
landmarks: [
{
name: '盐火灯塔',
description: '旧灯塔正在熄灭边缘摇晃,所有人都盯着它的火。',
visualDescription: '塔身被盐霜和旧火痕反复覆盖。',
dangerLevel: 'high',
sceneNpcNames: ['沈砺', '岚珀'],
connections: [
{
targetLandmarkName: '沉船码头',
relativePosition: 'forward',
summary: '顺着残灯下的潮道走,就会被拖进沉船码头。',
},
],
},
{
name: '沉船码头',
description: '假航灯把沉船和黑市都引到了这片雾港。',
visualDescription: '破碎船骨和黑帆在潮雾里若隐若现。',
dangerLevel: 'high',
sceneNpcNames: ['沈砺'],
connections: [
{
targetLandmarkName: '盐火灯塔',
relativePosition: 'back',
summary: '码头所有线头最终都会重新指回灯塔。',
},
],
},
],
});
}
if (debugLabel.startsWith('agent-foundation-playable-narrative-batch-')) {
playableNarrativeBatch += 1;
return JSON.stringify({
playableNpcs: [
{
name: '返灯人',
title: '失职守灯人',
role: '玩家前线身份',
description: '被迫返乡的守灯人,刚站回快要熄灭的旧灯塔。',
visualDescription: '风衣上沾着盐霜,眼底始终没有睡意。',
actionDescription: '先守住灯塔,再判断该不该相信旧友。',
sceneVisualDescription: '每次钟声响起时,他都像被过去拽住。',
relationshipHooks: ['这是玩家贴近世界的第一切口'],
tags: ['玩家视角', '守灯'],
},
],
});
}
if (debugLabel.startsWith('agent-foundation-playable-dossier-batch-')) {
playableDossierBatch += 1;
return JSON.stringify({
playableNpcs: [
{
name: '返灯人',
title: '失职守灯人',
role: '玩家前线身份',
description: '被迫返乡的守灯人,刚站回快要熄灭的旧灯塔。',
visualDescription: '风衣上沾着盐霜,眼底始终没有睡意。',
actionDescription: '先守住灯塔,再判断该不该相信旧友。',
sceneVisualDescription: '每次钟声响起时,他都像被过去拽住。',
relationshipHooks: ['这是玩家贴近世界的第一切口'],
tags: ['玩家视角', '守灯'],
},
],
});
}
if (debugLabel.startsWith('agent-foundation-story-narrative-batch-')) {
storyNarrativeBatch += 1;
return JSON.stringify({
storyNpcs: [
{
name: '沈砺',
title: '旧友兼宿敌',
role: '沉船商盟引路人',
description: '他像旧友,也像最早知道假航灯秘密的人。',
visualDescription: '衣角总带着潮水味,像是刚从夜雾里走出来。',
actionDescription: '会不断试探玩家到底愿不愿意回到旧航路。',
sceneVisualDescription: '总在钟声停下后的空隙里现身。',
relationshipHooks: ['和玩家共享一段无法翻篇的旧灯塔往事'],
tags: ['旧友', '宿敌'],
},
{
name: '岚珀',
title: '守灯会巡夜官',
role: '守灯会前台接口人',
description: '她负责把守灯会的怀疑与命令直接压到玩家面前。',
visualDescription: '披着潮湿斗篷,眼神总先看灯芯再看人。',
actionDescription: '要求玩家立刻证明自己还配站回灯塔。',
sceneVisualDescription: '总把巡夜灯举得很高,不给人躲闪空间。',
relationshipHooks: ['会逼玩家更早站队'],
tags: ['守灯会', '巡夜'],
},
],
});
}
if (debugLabel.startsWith('agent-foundation-story-dossier-batch-')) {
storyDossierBatch += 1;
return JSON.stringify({
storyNpcs: [
{
name: '沈砺',
title: '旧友兼宿敌',
role: '沉船商盟引路人',
description: '他像旧友,也像最早知道假航灯秘密的人。',
visualDescription: '衣角总带着潮水味,像是刚从夜雾里走出来。',
actionDescription: '会不断试探玩家到底愿不愿意回到旧航路。',
sceneVisualDescription: '总在钟声停下后的空隙里现身。',
relationshipHooks: ['和玩家共享一段无法翻篇的旧灯塔往事'],
tags: ['旧友', '宿敌'],
},
{
name: '岚珀',
title: '守灯会巡夜官',
role: '守灯会前台接口人',
description: '她负责把守灯会的怀疑与命令直接压到玩家面前。',
visualDescription: '披着潮湿斗篷,眼神总先看灯芯再看人。',
actionDescription: '要求玩家立刻证明自己还配站回灯塔。',
sceneVisualDescription: '总把巡夜灯举得很高,不给人躲闪空间。',
relationshipHooks: ['会逼玩家更早站队'],
tags: ['守灯会', '巡夜'],
},
],
});
}
throw new Error(`未覆盖的测试 debugLabel: ${debugLabel}`);
},
streamMessageContent: async () => {
throw new Error('这个测试不应该走流式接口');
},
} as UpstreamLlmClient;
}
test('foundation draft service builds draft fields directly from framework instead of reusing preview compiler output', async () => {
const service = new CustomWorldAgentFoundationDraftService(
createFoundationDraftLlmClient(),
);
const draft = await service.generate({
creatorIntent: {
sourceMode: 'freeform',
rawSettingText: '被海雾反复切开的列岛世界。',
worldHook: '旧灯塔、假航灯与失控航路重新把列岛撕开。',
themeKeywords: ['海岛', '悬疑'],
toneDirectives: ['冷峻', '潮湿'],
playerPremise: '玩家是被迫返乡的失职守灯人',
openingSituation: '开局时正站在即将熄灭的旧灯塔上',
coreConflicts: ['守灯会与沉船商盟争夺旧航路解释权'],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: ['潮雾钟声', '盐火灯塔'],
forbiddenDirectives: [],
},
anchorPack: {
creatorIntentSummary: '潮雾、旧灯塔、假航灯和被迫返乡的守灯人。',
},
});
const normalized = normalizeFoundationDraftProfile(draft);
const legacyResultProfile = (draft as Record<string, unknown>)
.legacyResultProfile as Record<string, unknown> | undefined;
const legacyStoryNpcs = Array.isArray(legacyResultProfile?.storyNpcs)
? (legacyResultProfile?.storyNpcs as Array<Record<string, unknown>>)
: [];
assert.ok(normalized);
assert.equal(normalized?.name, '潮雾列岛');
assert.equal(
normalized?.summary,
'潮雾列岛正在被假航灯和沉船商盟重新切开。',
);
assert.equal(normalized?.playableNpcs.length, 1);
assert.equal(normalized?.storyNpcs.length, 2);
assert.equal(normalized?.storyNpcs[0]?.name, '沈砺');
assert.match(
normalized?.storyNpcs[0]?.summary ?? '',
/||/u,
);
assert.equal(
normalized?.storyNpcs[0]?.publicMask,
'衣角总带着潮水味,像是刚从夜雾里走出来。',
);
assert.equal(normalized?.landmarks.length, 2);
assert.equal(normalized?.landmarks[0]?.name, '盐火灯塔');
assert.equal(normalized?.sceneChapters.length, 2);
assert.equal(legacyResultProfile?.name, '潮雾列岛');
assert.equal(
legacyResultProfile?.scenarioPackId,
'scenario-pack:潮雾列岛',
);
assert.equal(
legacyResultProfile?.campaignPackId,
'campaign-pack:潮雾列岛',
);
assert.equal(legacyStoryNpcs[0]?.name, '沈砺');
assert.equal(legacyStoryNpcs[0]?.backstory, undefined);
});

Some files were not shown because too many files have changed in this diff Show More