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:
@@ -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',
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ?? []),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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}」存在无效的目标场景连接。`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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'];
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -1 +0,0 @@
|
||||
export * from './inventoryMutationService.js';
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './questProgressionService.js';
|
||||
export { generateQuestForNpcEncounter } from '../../services/questService.js';
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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 =
|
||||
@@ -0,0 +1,10 @@
|
||||
import {
|
||||
resolveRuntimeStoryAction,
|
||||
} from './RpgRuntimeStoryActionDomain.js';
|
||||
|
||||
/**
|
||||
* RPG runtime story 动作服务入口。
|
||||
* 工作包 G 后 runtime action 主链直接落到新域实现,不再继续桥接旧热点文件。
|
||||
*/
|
||||
export { resolveRuntimeStoryAction };
|
||||
export const resolveRpgRuntimeStoryAction = resolveRuntimeStoryAction;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { buildLegacyCurrentStory } from './RpgRuntimeSessionDomain.js';
|
||||
|
||||
/**
|
||||
* RPG runtime story 展示兼容编译器。
|
||||
* 当前仍复用 legacy currentStory 投影规则,但真实落点已经切到新域目录。
|
||||
*/
|
||||
export { buildLegacyCurrentStory };
|
||||
export const buildRpgRuntimeLegacyCurrentStory = buildLegacyCurrentStory;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getRuntimeStoryState } from './RpgRuntimeStoryActionDomain.js';
|
||||
|
||||
/**
|
||||
* RPG runtime story 状态读取入口。
|
||||
* 工作包 G 先把状态读取从旧热点文件迁到新域命名,再保持动作与状态的物理落点分离。
|
||||
*/
|
||||
export { getRuntimeStoryState };
|
||||
export const getRpgRuntimeStoryState = getRuntimeStoryState;
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './runtimeItemResolutionService.js';
|
||||
export { generateRuntimeItemIntents } from '../../services/runtimeItemService.js';
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
100
server-node/src/repositories/RpgAgentSessionRepository.ts
Normal file
100
server-node/src/repositories/RpgAgentSessionRepository.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
433
server-node/src/repositories/RpgWorldProfileRepository.ts
Normal file
433
server-node/src/repositories/RpgWorldProfileRepository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
116
server-node/src/repositories/rpgWorldRepositoryShared.ts
Normal file
116
server-node/src/repositories/rpgWorldRepositoryShared.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
151
server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts
Normal file
151
server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type {
|
||||
ProfileSaveArchiveResumeResponse,
|
||||
SavedGameSnapshotInput,
|
||||
} from '../../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { AppContext } from '../../context.js';
|
||||
import { badRequest, notFound } from '../../errors.js';
|
||||
import { asyncHandler, sendApiResponse } from '../../http.js';
|
||||
import { requireJwtAuth } from '../../middleware/auth.js';
|
||||
import { routeMeta } from '../../middleware/routeMeta.js';
|
||||
import {
|
||||
hydrateSavedSnapshot,
|
||||
normalizeSavedSnapshotPayload,
|
||||
} from '../../modules/runtime/runtimeSnapshotHydration.js';
|
||||
|
||||
const saveSnapshotSchema = z.object({
|
||||
gameState: z.unknown(),
|
||||
bottomTab: z.string().trim().min(1),
|
||||
currentStory: z.unknown().nullable().optional().default(null),
|
||||
savedAt: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
export const RPG_ENTRY_SAVE_ROUTE_BASE_PATH = '/api/runtime/save';
|
||||
export const RPG_ENTRY_SAVE_ARCHIVE_ROUTE_BASE_PATH =
|
||||
'/api/runtime/profile/save-archives';
|
||||
export const RPG_ENTRY_SAVE_ARCHIVE_LEGACY_ROUTE_BASE_PATH =
|
||||
'/api/profile/save-archives';
|
||||
|
||||
function readParam(param: string | string[] | undefined) {
|
||||
return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || '';
|
||||
}
|
||||
|
||||
function routeCompatPaths(path: string) {
|
||||
return [path, path.replace('runtime/', '')] as const;
|
||||
}
|
||||
|
||||
export function createRpgEntrySaveRoutes(context: AppContext) {
|
||||
const router = Router();
|
||||
const requireAuth = requireJwtAuth(context.config, context.userRepository);
|
||||
|
||||
router.get(
|
||||
'/runtime/save/snapshot',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.snapshot.get' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
sendApiResponse(
|
||||
response,
|
||||
hydrateSavedSnapshot(
|
||||
await context.rpgRuntimeSnapshotRepository.getSnapshot(request.userId!),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/runtime/save/snapshot',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.snapshot.put' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = saveSnapshotSchema.parse(
|
||||
request.body,
|
||||
) as SavedGameSnapshotInput;
|
||||
const normalizedSnapshot = normalizeSavedSnapshotPayload({
|
||||
savedAt: payload.savedAt || new Date().toISOString(),
|
||||
gameState: payload.gameState,
|
||||
bottomTab: payload.bottomTab,
|
||||
currentStory: payload.currentStory ?? null,
|
||||
});
|
||||
sendApiResponse(
|
||||
response,
|
||||
hydrateSavedSnapshot(
|
||||
await context.rpgRuntimeSnapshotRepository.putSnapshot(
|
||||
request.userId!,
|
||||
normalizedSnapshot,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/runtime/save/snapshot',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.snapshot.delete' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
await context.rpgRuntimeSnapshotRepository.deleteSnapshot(request.userId!);
|
||||
sendApiResponse(response, { ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
[
|
||||
'/runtime/profile/save-archives/:worldKey',
|
||||
'/profile/save-archives/:worldKey',
|
||||
].forEach((path, index) => {
|
||||
router.post(
|
||||
path,
|
||||
requireAuth,
|
||||
routeMeta({
|
||||
operation:
|
||||
index === 0
|
||||
? 'profile.saveArchives.resume'
|
||||
: 'profile.saveArchives.resume.compat',
|
||||
}),
|
||||
asyncHandler(async (request, response) => {
|
||||
const worldKey = readParam(request.params.worldKey);
|
||||
if (!worldKey) {
|
||||
throw badRequest('worldKey 不能为空');
|
||||
}
|
||||
|
||||
const resumedArchive =
|
||||
await context.rpgSaveArchiveRepository.resumeProfileSaveArchive(
|
||||
request.userId!,
|
||||
worldKey,
|
||||
);
|
||||
|
||||
if (!resumedArchive) {
|
||||
throw notFound('指定存档不存在');
|
||||
}
|
||||
|
||||
sendApiResponse<ProfileSaveArchiveResumeResponse>(response, {
|
||||
entry: resumedArchive.entry,
|
||||
snapshot: hydrateSavedSnapshot(resumedArchive.snapshot)!,
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
routeCompatPaths('/api/runtime/profile/save-archives').forEach((path, index) => {
|
||||
router.get(
|
||||
path.replace('/api/', '/'),
|
||||
requireAuth,
|
||||
routeMeta({
|
||||
operation:
|
||||
index === 0
|
||||
? 'profile.saveArchives.list'
|
||||
: 'profile.saveArchives.list.compat',
|
||||
}),
|
||||
asyncHandler(async (request, response) => {
|
||||
sendApiResponse(response, {
|
||||
entries: await context.rpgSaveArchiveRepository.listProfileSaveArchives(
|
||||
request.userId!,
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
338
server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts
Normal file
338
server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { ListCustomWorldWorksResponse } from '../../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import type {
|
||||
CustomWorldGalleryDetailResponse,
|
||||
CustomWorldGalleryResponse,
|
||||
CustomWorldLibraryMutationResponse,
|
||||
CustomWorldLibraryResponse,
|
||||
} from '../../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { AppContext } from '../../context.js';
|
||||
import { badRequest, conflict, notFound } from '../../errors.js';
|
||||
import { asyncHandler, jsonClone, sendApiResponse } from '../../http.js';
|
||||
import { requireJwtAuth } from '../../middleware/auth.js';
|
||||
import { routeMeta } from '../../middleware/routeMeta.js';
|
||||
import { CustomWorldAgentPublishingService } from '../../services/customWorldAgentPublishingService.js';
|
||||
|
||||
const jsonObjectSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
const customWorldProfileSchema = z.object({
|
||||
profile: jsonObjectSchema,
|
||||
});
|
||||
|
||||
export const RPG_WORLD_LIBRARY_ROUTE_BASE_PATH =
|
||||
'/api/runtime/custom-world-library';
|
||||
export const RPG_WORLD_GALLERY_ROUTE_BASE_PATH =
|
||||
'/api/runtime/custom-world-gallery';
|
||||
export const RPG_WORLD_WORKS_ROUTE_BASE_PATH =
|
||||
'/api/runtime/custom-world/works';
|
||||
const AGENT_DRAFT_PROFILE_ID_PREFIX = 'agent-draft-';
|
||||
|
||||
function readParam(param: string | string[] | undefined) {
|
||||
return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || '';
|
||||
}
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function resolveAgentSessionIdFromProfileId(profileId: string) {
|
||||
if (!profileId.startsWith(AGENT_DRAFT_PROFILE_ID_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionId = profileId.slice(AGENT_DRAFT_PROFILE_ID_PREFIX.length).trim();
|
||||
return sessionId || null;
|
||||
}
|
||||
|
||||
function resolvePublishedWorldName(profile: unknown) {
|
||||
const profileRecord =
|
||||
profile && typeof profile === 'object' && !Array.isArray(profile)
|
||||
? (profile as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
return toText(profileRecord?.name) || '当前世界';
|
||||
}
|
||||
|
||||
async function syncAgentSessionPublishedState(params: {
|
||||
context: AppContext;
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
worldName: string;
|
||||
qualityFindings: Array<{
|
||||
id: string;
|
||||
severity: 'info' | 'warning' | 'blocker';
|
||||
code: string;
|
||||
targetId?: string | null;
|
||||
message: string;
|
||||
}>;
|
||||
}) {
|
||||
const publishedQualityFindings = params.qualityFindings.filter(
|
||||
(entry) => entry.severity !== 'blocker',
|
||||
);
|
||||
const publishedState = {
|
||||
stage: 'published' as const,
|
||||
qualityFindings: publishedQualityFindings,
|
||||
};
|
||||
|
||||
await params.context.customWorldAgentSessions.replaceDerivedState(
|
||||
params.userId,
|
||||
params.sessionId,
|
||||
publishedState,
|
||||
);
|
||||
await params.context.customWorldAgentSessions.appendCheckpoint(
|
||||
params.userId,
|
||||
params.sessionId,
|
||||
{
|
||||
label: `发布世界 ${params.worldName}`,
|
||||
snapshot: publishedState,
|
||||
},
|
||||
);
|
||||
await params.context.customWorldAgentSessions.appendMessage(
|
||||
params.userId,
|
||||
params.sessionId,
|
||||
{
|
||||
id: `message-${Date.now().toString(36)}-library-publish`,
|
||||
role: 'assistant',
|
||||
kind: 'action_result',
|
||||
text:
|
||||
publishedQualityFindings.length > 0
|
||||
? `世界「${params.worldName}」已发布,并保留 ${publishedQualityFindings.length} 条 warning 供后续继续优化。`
|
||||
: `世界「${params.worldName}」已正式发布,可以进入作品库与世界入口。`,
|
||||
createdAt: new Date().toISOString(),
|
||||
relatedOperationId: null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveAuthDisplayName(context: AppContext, userId: string) {
|
||||
const user = await context.userRepository.findById(userId);
|
||||
if (!user) {
|
||||
throw notFound('user not found');
|
||||
}
|
||||
|
||||
return user.displayName?.trim() || '玩家';
|
||||
}
|
||||
|
||||
export function createRpgWorldLibraryRoutes(context: AppContext) {
|
||||
const router = Router();
|
||||
const requireAuth = requireJwtAuth(context.config, context.userRepository);
|
||||
const publishingService = new CustomWorldAgentPublishingService(
|
||||
context.rpgWorldProfileRepository,
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/custom-world-gallery',
|
||||
routeMeta({ operation: 'runtime.customWorldGallery.list' }),
|
||||
asyncHandler(async (_request, response) => {
|
||||
sendApiResponse(response, {
|
||||
entries:
|
||||
await context.rpgWorldLibraryRepository.listPublishedCustomWorldGallery(),
|
||||
} satisfies CustomWorldGalleryResponse);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/custom-world-gallery/:ownerUserId/:profileId',
|
||||
routeMeta({ operation: 'runtime.customWorldGallery.detail' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const ownerUserId = readParam(request.params.ownerUserId);
|
||||
const profileId = readParam(request.params.profileId);
|
||||
if (!ownerUserId || !profileId) {
|
||||
throw badRequest('ownerUserId and profileId are required');
|
||||
}
|
||||
|
||||
const entry =
|
||||
await context.rpgWorldLibraryRepository.getPublishedCustomWorldGalleryDetail(
|
||||
ownerUserId,
|
||||
profileId,
|
||||
);
|
||||
if (!entry) {
|
||||
throw notFound('public custom world not found');
|
||||
}
|
||||
|
||||
sendApiResponse(response, {
|
||||
entry,
|
||||
} satisfies CustomWorldGalleryDetailResponse);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/custom-world/works',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.customWorldWorks.list' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
sendApiResponse<ListCustomWorldWorksResponse>(response, {
|
||||
items: await context.rpgWorldWorkSummaryService.list(request.userId!),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/custom-world-library',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.customWorldLibrary.list' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
sendApiResponse(response, {
|
||||
entries: await context.rpgWorldLibraryRepository.listCustomWorldProfiles(
|
||||
request.userId!,
|
||||
),
|
||||
} satisfies CustomWorldLibraryResponse);
|
||||
}),
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/runtime/custom-world-library/:profileId',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.customWorldLibrary.upsert' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const profileId = readParam(request.params.profileId);
|
||||
if (!profileId) {
|
||||
throw badRequest('profileId is required');
|
||||
}
|
||||
|
||||
const payload = customWorldProfileSchema.parse(request.body);
|
||||
const authorDisplayName = await resolveAuthDisplayName(
|
||||
context,
|
||||
request.userId!,
|
||||
);
|
||||
sendApiResponse(
|
||||
response,
|
||||
await context.rpgWorldLibraryRepository.upsertCustomWorldProfile(
|
||||
request.userId!,
|
||||
profileId,
|
||||
jsonClone(payload.profile),
|
||||
authorDisplayName,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/runtime/custom-world-library/:profileId',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.customWorldLibrary.delete' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const profileId = readParam(request.params.profileId);
|
||||
if (!profileId) {
|
||||
throw badRequest('profileId is required');
|
||||
}
|
||||
|
||||
sendApiResponse(response, {
|
||||
entries: await context.rpgWorldLibraryRepository.deleteCustomWorldProfile(
|
||||
request.userId!,
|
||||
profileId,
|
||||
),
|
||||
} satisfies CustomWorldLibraryResponse);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/custom-world-library/:profileId/publish',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.customWorldLibrary.publish' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const profileId = readParam(request.params.profileId);
|
||||
if (!profileId) {
|
||||
throw badRequest('profileId is required');
|
||||
}
|
||||
|
||||
const authorDisplayName = await resolveAuthDisplayName(
|
||||
context,
|
||||
request.userId!,
|
||||
);
|
||||
const agentSessionId = resolveAgentSessionIdFromProfileId(profileId);
|
||||
if (agentSessionId) {
|
||||
const agentSession = await context.customWorldAgentSessions.get(
|
||||
request.userId!,
|
||||
agentSessionId,
|
||||
);
|
||||
|
||||
if (agentSession) {
|
||||
try {
|
||||
publishingService.buildPublishReadiness({
|
||||
sessionId: agentSessionId,
|
||||
draftProfile: agentSession.draftProfile,
|
||||
qualityFindings: agentSession.qualityFindings,
|
||||
});
|
||||
} catch (error) {
|
||||
throw conflict(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: '当前世界还没有通过发布校验。',
|
||||
);
|
||||
}
|
||||
|
||||
const publishResult = await publishingService.publishSessionDraft({
|
||||
userId: request.userId!,
|
||||
authorDisplayName,
|
||||
sessionId: agentSessionId,
|
||||
draftProfile:
|
||||
(agentSession.draftProfile ?? {}) as Record<string, unknown>,
|
||||
qualityFindings: agentSession.qualityFindings,
|
||||
});
|
||||
await syncAgentSessionPublishedState({
|
||||
context,
|
||||
userId: request.userId!,
|
||||
sessionId: agentSessionId,
|
||||
worldName: resolvePublishedWorldName(publishResult.publishedProfile),
|
||||
qualityFindings: agentSession.qualityFindings,
|
||||
});
|
||||
sendApiResponse(
|
||||
response,
|
||||
publishResult.mutation satisfies CustomWorldLibraryMutationResponse,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const mutation = await context.rpgWorldLibraryRepository.publishCustomWorldProfile(
|
||||
request.userId!,
|
||||
profileId,
|
||||
authorDisplayName,
|
||||
);
|
||||
if (!mutation) {
|
||||
throw notFound('custom world not found');
|
||||
}
|
||||
|
||||
sendApiResponse(
|
||||
response,
|
||||
mutation satisfies CustomWorldLibraryMutationResponse,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/custom-world-library/:profileId/unpublish',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.customWorldLibrary.unpublish' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const profileId = readParam(request.params.profileId);
|
||||
if (!profileId) {
|
||||
throw badRequest('profileId is required');
|
||||
}
|
||||
|
||||
const authorDisplayName = await resolveAuthDisplayName(
|
||||
context,
|
||||
request.userId!,
|
||||
);
|
||||
const mutation =
|
||||
await context.rpgWorldLibraryRepository.unpublishCustomWorldProfile(
|
||||
request.userId!,
|
||||
profileId,
|
||||
authorDisplayName,
|
||||
);
|
||||
if (!mutation) {
|
||||
throw notFound('custom world not found');
|
||||
}
|
||||
|
||||
sendApiResponse(
|
||||
response,
|
||||
mutation satisfies CustomWorldLibraryMutationResponse,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
214
server-node/src/routes/rpg-profile/rpgProfileRoutes.ts
Normal file
214
server-node/src/routes/rpg-profile/rpgProfileRoutes.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type {
|
||||
PlatformBrowseHistoryBatchSyncRequest,
|
||||
PlatformBrowseHistoryResponse,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileWalletLedgerResponse,
|
||||
RuntimeSettings,
|
||||
} from '../../../../packages/shared/src/contracts/runtime.js';
|
||||
import { PLATFORM_THEMES } from '../../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { AppContext } from '../../context.js';
|
||||
import { asyncHandler, sendApiResponse } from '../../http.js';
|
||||
import { requireJwtAuth } from '../../middleware/auth.js';
|
||||
import { routeMeta } from '../../middleware/routeMeta.js';
|
||||
|
||||
const platformBrowseHistoryEntrySchema = z.object({
|
||||
ownerUserId: z.string().trim().min(1),
|
||||
profileId: z.string().trim().min(1),
|
||||
worldName: z.string().trim().min(1),
|
||||
subtitle: z.string().trim().optional().default(''),
|
||||
summaryText: z.string().trim().optional().default(''),
|
||||
coverImageSrc: z.string().trim().nullable().optional().default(null),
|
||||
themeMode: z.string().trim().optional().default('mythic'),
|
||||
authorDisplayName: z.string().trim().optional().default('玩家'),
|
||||
visitedAt: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
const platformBrowseHistoryBatchSchema = z.object({
|
||||
entries: z.array(platformBrowseHistoryEntrySchema).max(100),
|
||||
});
|
||||
|
||||
const settingsSchema = z.object({
|
||||
musicVolume: z.number().min(0).max(1),
|
||||
platformTheme: z.enum(PLATFORM_THEMES),
|
||||
});
|
||||
|
||||
export const RPG_PROFILE_ROUTE_BASE_PATH = '/api/runtime/profile';
|
||||
export const RPG_PROFILE_LEGACY_ROUTE_BASE_PATH = '/api/profile';
|
||||
|
||||
function routeCompatPaths(path: string) {
|
||||
return [path, path.replace('runtime/', '')] as const;
|
||||
}
|
||||
|
||||
export function createRpgProfileRoutes(context: AppContext) {
|
||||
const router = Router();
|
||||
const requireAuth = requireJwtAuth(context.config, context.userRepository);
|
||||
|
||||
routeCompatPaths('/api/runtime/profile/dashboard').forEach((path, index) => {
|
||||
router.get(
|
||||
path.replace('/api/', '/'),
|
||||
requireAuth,
|
||||
routeMeta({
|
||||
operation:
|
||||
index === 0
|
||||
? 'profile.dashboard.get'
|
||||
: 'profile.dashboard.get.compat',
|
||||
}),
|
||||
asyncHandler(async (request, response) => {
|
||||
sendApiResponse<ProfileDashboardSummary>(
|
||||
response,
|
||||
await context.rpgProfileDashboardRepository.getProfileDashboard(
|
||||
request.userId!,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
routeCompatPaths('/api/runtime/profile/wallet-ledger').forEach((path, index) => {
|
||||
router.get(
|
||||
path.replace('/api/', '/'),
|
||||
requireAuth,
|
||||
routeMeta({
|
||||
operation:
|
||||
index === 0
|
||||
? 'profile.walletLedger.list'
|
||||
: 'profile.walletLedger.list.compat',
|
||||
}),
|
||||
asyncHandler(async (request, response) => {
|
||||
sendApiResponse<ProfileWalletLedgerResponse>(response, {
|
||||
entries:
|
||||
await context.rpgProfileDashboardRepository.listProfileWalletLedger(
|
||||
request.userId!,
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
routeCompatPaths('/api/runtime/profile/play-stats').forEach((path, index) => {
|
||||
router.get(
|
||||
path.replace('/api/', '/'),
|
||||
requireAuth,
|
||||
routeMeta({
|
||||
operation:
|
||||
index === 0
|
||||
? 'profile.playStats.get'
|
||||
: 'profile.playStats.get.compat',
|
||||
}),
|
||||
asyncHandler(async (request, response) => {
|
||||
sendApiResponse<ProfilePlayStatsResponse>(
|
||||
response,
|
||||
await context.rpgProfileDashboardRepository.getProfilePlayStats(
|
||||
request.userId!,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
routeCompatPaths('/api/runtime/profile/browse-history').forEach((path, index) => {
|
||||
router.get(
|
||||
path.replace('/api/', '/'),
|
||||
requireAuth,
|
||||
routeMeta({
|
||||
operation:
|
||||
index === 0
|
||||
? 'profile.browseHistory.list'
|
||||
: 'profile.browseHistory.list.compat',
|
||||
}),
|
||||
asyncHandler(async (request, response) => {
|
||||
sendApiResponse<PlatformBrowseHistoryResponse>(response, {
|
||||
entries: await context.rpgBrowseHistoryRepository.listPlatformBrowseHistory(
|
||||
request.userId!,
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
path.replace('/api/', '/'),
|
||||
requireAuth,
|
||||
routeMeta({
|
||||
operation:
|
||||
index === 0
|
||||
? 'profile.browseHistory.upsert'
|
||||
: 'profile.browseHistory.upsert.compat',
|
||||
}),
|
||||
asyncHandler(async (request, response) => {
|
||||
const rawBody =
|
||||
request.body && typeof request.body === 'object' ? request.body : {};
|
||||
const payload = (
|
||||
'entries' in rawBody
|
||||
? platformBrowseHistoryBatchSchema.parse(rawBody)
|
||||
: platformBrowseHistoryEntrySchema.parse(rawBody)
|
||||
) as
|
||||
| PlatformBrowseHistoryBatchSyncRequest
|
||||
| PlatformBrowseHistoryWriteEntry;
|
||||
|
||||
const entries = 'entries' in payload ? payload.entries : [payload];
|
||||
|
||||
sendApiResponse<PlatformBrowseHistoryResponse>(response, {
|
||||
entries:
|
||||
await context.rpgBrowseHistoryRepository.upsertPlatformBrowseHistoryEntries(
|
||||
request.userId!,
|
||||
entries,
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
path.replace('/api/', '/'),
|
||||
requireAuth,
|
||||
routeMeta({
|
||||
operation:
|
||||
index === 0
|
||||
? 'profile.browseHistory.clear'
|
||||
: 'profile.browseHistory.clear.compat',
|
||||
}),
|
||||
asyncHandler(async (request, response) => {
|
||||
await context.rpgBrowseHistoryRepository.clearPlatformBrowseHistory(
|
||||
request.userId!,
|
||||
);
|
||||
sendApiResponse<PlatformBrowseHistoryResponse>(response, {
|
||||
entries: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/api/runtime/settings'.replace('/api/', '/'),
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.settings.get' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
sendApiResponse(
|
||||
response,
|
||||
await context.rpgProfileDashboardRepository.getSettings(request.userId!),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/api/runtime/settings'.replace('/api/', '/'),
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.settings.put' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = settingsSchema.parse(request.body) as RuntimeSettings;
|
||||
sendApiResponse(
|
||||
response,
|
||||
await context.rpgProfileDashboardRepository.putSettings(
|
||||
request.userId!,
|
||||
payload,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
370
server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts
Normal file
370
server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type {
|
||||
QuestGenerationRequest,
|
||||
RuntimeItemIntentRequest,
|
||||
} from '../../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js';
|
||||
import type {
|
||||
CharacterChatReplyRequest,
|
||||
CharacterChatSuggestionsRequest,
|
||||
CharacterChatSummaryRequest,
|
||||
NpcChatDialogueRequest,
|
||||
NpcChatTurnRequest,
|
||||
NpcRecruitDialogueRequest,
|
||||
StoryRequestPayload,
|
||||
} from '../../../../packages/shared/src/contracts/rpgRuntimeChat.js';
|
||||
import type { GenerateCustomWorldProfileInput } from '../../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { AppContext } from '../../context.js';
|
||||
import { asyncHandler, sendApiResponse } from '../../http.js';
|
||||
import { requireJwtAuth } from '../../middleware/auth.js';
|
||||
import { routeMeta } from '../../middleware/routeMeta.js';
|
||||
import {
|
||||
generateCharacterChatSuggestionsFromOrchestrator,
|
||||
generateCharacterChatSummaryFromOrchestrator,
|
||||
streamCharacterChatReplyFromOrchestrator,
|
||||
streamNpcChatDialogueFromOrchestrator,
|
||||
streamNpcChatTurnFromOrchestrator,
|
||||
streamNpcRecruitDialogueFromOrchestrator,
|
||||
} from '../../modules/ai/chatOrchestrator.js';
|
||||
import { generateCustomWorldProfileFromOrchestrator } from '../../modules/ai/customWorldOrchestrator.js';
|
||||
import {
|
||||
characterChatReplyRequestSchema,
|
||||
characterChatSuggestionsRequestSchema,
|
||||
characterChatSummaryRequestSchema,
|
||||
npcChatDialogueRequestSchema,
|
||||
npcChatTurnRequestSchema,
|
||||
npcRecruitDialogueRequestSchema,
|
||||
} from '../../services/chatService.js';
|
||||
import {
|
||||
customWorldCoverImageSchema,
|
||||
customWorldCoverUploadSchema,
|
||||
generateCustomWorldCoverImage,
|
||||
uploadCustomWorldCoverImage,
|
||||
} from '../../services/customWorldCoverAssetService.js';
|
||||
import { generateCustomWorldEntity } from '../../services/customWorldEntityGenerationService.js';
|
||||
import { generateSceneNpcForLandmark } from '../../services/customWorldSceneNpcGenerationService.js';
|
||||
import { generateQuestForNpcEncounter } from '../../services/questService.js';
|
||||
import { generateRuntimeItemIntents } from '../../services/runtimeItemService.js';
|
||||
import {
|
||||
generateSceneImage,
|
||||
sceneImageSchema,
|
||||
} from '../../services/sceneImageService.js';
|
||||
import {
|
||||
generateHighQualityInitialStory,
|
||||
generateHighQualityNextStory,
|
||||
parseStoryRequest,
|
||||
} from '../../services/storyService.js';
|
||||
|
||||
const jsonObjectSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
const customWorldProfileGenerationSchema = z.object({
|
||||
settingText: z.string().trim().min(1),
|
||||
creatorIntent: jsonObjectSchema.nullish(),
|
||||
generationMode: z.enum(['fast', 'full']).optional(),
|
||||
});
|
||||
|
||||
const customWorldSceneNpcSchema = z.object({
|
||||
profile: jsonObjectSchema,
|
||||
landmarkId: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
const customWorldEntitySchema = z.object({
|
||||
profile: jsonObjectSchema,
|
||||
kind: z.enum(['playable', 'story', 'landmark']),
|
||||
});
|
||||
|
||||
const runtimeItemIntentSchema = z.object({
|
||||
context: jsonObjectSchema,
|
||||
plans: z.array(jsonObjectSchema),
|
||||
});
|
||||
|
||||
const questGenerationSchema = z.object({
|
||||
state: jsonObjectSchema,
|
||||
encounter: jsonObjectSchema,
|
||||
});
|
||||
|
||||
const llmProxySchema = jsonObjectSchema;
|
||||
|
||||
export const RPG_RUNTIME_AI_ASSIST_ROUTE_BASE_PATH = '/api/runtime';
|
||||
|
||||
export function createRpgRuntimeAiAssistRoutes(context: AppContext) {
|
||||
const router = Router();
|
||||
const requireAuth = requireJwtAuth(context.config, context.userRepository);
|
||||
const handleCustomWorldEntityGeneration = asyncHandler(
|
||||
async (request, response) => {
|
||||
const payload = customWorldEntitySchema.parse(request.body) as {
|
||||
profile: Record<string, unknown>;
|
||||
kind: 'playable' | 'story' | 'landmark';
|
||||
};
|
||||
sendApiResponse(
|
||||
response,
|
||||
await generateCustomWorldEntity(context.llmClient, payload),
|
||||
);
|
||||
},
|
||||
);
|
||||
const handleCustomWorldSceneNpcGeneration = asyncHandler(
|
||||
async (request, response) => {
|
||||
const payload = customWorldSceneNpcSchema.parse(request.body) as {
|
||||
profile: Record<string, unknown>;
|
||||
landmarkId: string;
|
||||
};
|
||||
sendApiResponse(response, {
|
||||
npc: await generateSceneNpcForLandmark(context.llmClient, payload),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/llm/chat/completions',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.llm.chatCompletionsProxy' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const body = llmProxySchema.parse(request.body);
|
||||
await context.llmClient.forwardCompletion(request, body, response);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/custom-world/cover-image',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.customWorld.coverImage' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = customWorldCoverImageSchema.parse(request.body);
|
||||
sendApiResponse(response, await generateCustomWorldCoverImage(context, payload));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/custom-world/cover-upload',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.customWorld.coverUpload' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = customWorldCoverUploadSchema.parse(request.body);
|
||||
sendApiResponse(response, await uploadCustomWorldCoverImage(context, payload));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/custom-world/scene-image',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.customWorld.sceneImage' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = sceneImageSchema.parse(request.body);
|
||||
sendApiResponse(response, await generateSceneImage(context, payload));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/custom-world/entity',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.customWorld.entity' }),
|
||||
handleCustomWorldEntityGeneration,
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/custom-world/entity',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.customWorld.entity.compat' }),
|
||||
handleCustomWorldEntityGeneration,
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/custom-world/scene-npc',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.customWorld.sceneNpc' }),
|
||||
handleCustomWorldSceneNpcGeneration,
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/custom-world/scene-npc',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.customWorld.sceneNpc.compat' }),
|
||||
handleCustomWorldSceneNpcGeneration,
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/custom-world/profile',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.customWorld.profile' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = customWorldProfileGenerationSchema.parse(
|
||||
request.body,
|
||||
) as GenerateCustomWorldProfileInput;
|
||||
sendApiResponse(
|
||||
response,
|
||||
await generateCustomWorldProfileFromOrchestrator(
|
||||
context.llmClient,
|
||||
payload,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/story/initial',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.story.initial' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = parseStoryRequest(request.body) as StoryRequestPayload;
|
||||
sendApiResponse(
|
||||
response,
|
||||
await generateHighQualityInitialStory(context.llmClient, payload),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/story/continue',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.story.continue' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = parseStoryRequest(request.body) as StoryRequestPayload;
|
||||
sendApiResponse(
|
||||
response,
|
||||
await generateHighQualityNextStory(context.llmClient, payload),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/chat/character/suggestions',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.chat.character.suggestions' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = characterChatSuggestionsRequestSchema.parse(
|
||||
request.body,
|
||||
) as CharacterChatSuggestionsRequest;
|
||||
sendApiResponse(response, {
|
||||
text: await generateCharacterChatSuggestionsFromOrchestrator(
|
||||
context.llmClient,
|
||||
payload,
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/chat/character/summary',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.chat.character.summary' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = characterChatSummaryRequestSchema.parse(
|
||||
request.body,
|
||||
) as CharacterChatSummaryRequest;
|
||||
sendApiResponse(response, {
|
||||
text: await generateCharacterChatSummaryFromOrchestrator(
|
||||
context.llmClient,
|
||||
payload,
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/chat/character/reply/stream',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.chat.character.replyStream' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = characterChatReplyRequestSchema.parse(
|
||||
request.body,
|
||||
) as CharacterChatReplyRequest;
|
||||
await streamCharacterChatReplyFromOrchestrator(context.llmClient, {
|
||||
request,
|
||||
response,
|
||||
payload,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/chat/npc/dialogue/stream',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.chat.npc.dialogueStream' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = npcChatDialogueRequestSchema.parse(
|
||||
request.body,
|
||||
) as NpcChatDialogueRequest;
|
||||
await streamNpcChatDialogueFromOrchestrator(context.llmClient, {
|
||||
request,
|
||||
response,
|
||||
payload,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/chat/npc/turn/stream',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.chat.npc.turnStream' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = npcChatTurnRequestSchema.parse(
|
||||
request.body,
|
||||
) as NpcChatTurnRequest;
|
||||
await streamNpcChatTurnFromOrchestrator(context.llmClient, {
|
||||
request,
|
||||
response,
|
||||
payload,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/chat/npc/recruit/stream',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.chat.npc.recruitStream' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = npcRecruitDialogueRequestSchema.parse(
|
||||
request.body,
|
||||
) as NpcRecruitDialogueRequest;
|
||||
await streamNpcRecruitDialogueFromOrchestrator(context.llmClient, {
|
||||
request,
|
||||
response,
|
||||
payload,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/items/runtime-intent',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.items.intent' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = runtimeItemIntentSchema.parse(
|
||||
request.body,
|
||||
) as RuntimeItemIntentRequest;
|
||||
sendApiResponse(response, {
|
||||
intents: await generateRuntimeItemIntents(context.llmClient, payload),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/quests/generate',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.quests.generate' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = questGenerationSchema.parse(
|
||||
request.body,
|
||||
) as QuestGenerationRequest;
|
||||
sendApiResponse(
|
||||
response,
|
||||
await generateQuestForNpcEncounter(context.llmClient, payload),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/ws/health',
|
||||
requireAuth,
|
||||
routeMeta({ operation: 'runtime.ws.health' }),
|
||||
(_request, response) => {
|
||||
sendApiResponse(response, {
|
||||
ok: true,
|
||||
message: 'websocket routes reserved for future real-time support',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -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(
|
||||
@@ -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;
|
||||
}
|
||||
524
server-node/src/routes/rpgRouteBoundaries.test.ts
Normal file
524
server-node/src/routes/rpgRouteBoundaries.test.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createApp } from '../app.ts';
|
||||
import type { AppConfig } from '../config.ts';
|
||||
import { createAppContext } from '../server.ts';
|
||||
import { httpRequest, type TestRequestInit } from '../testHttp.ts';
|
||||
|
||||
function createTestConfig(testName: string): AppConfig {
|
||||
const tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `genarrative-rpg-routes-${testName}-`),
|
||||
);
|
||||
|
||||
return {
|
||||
nodeEnv: 'test',
|
||||
projectRoot: tempRoot,
|
||||
publicDir: path.join(tempRoot, 'public'),
|
||||
logsDir: path.join(tempRoot, 'logs'),
|
||||
dataDir: path.join(tempRoot, 'data'),
|
||||
rawEnv: {},
|
||||
databaseUrl: `pg-mem://genarrative-rpg-routes-${testName}`,
|
||||
serverAddr: ':0',
|
||||
logLevel: 'silent',
|
||||
editorApiEnabled: true,
|
||||
assetsApiEnabled: true,
|
||||
jwtSecret: 'test-secret',
|
||||
jwtExpiresIn: '7d',
|
||||
jwtIssuer: 'genarrative-rpg-routes-test',
|
||||
llm: {
|
||||
baseUrl: 'https://example.invalid',
|
||||
apiKey: '',
|
||||
model: 'test-model',
|
||||
},
|
||||
dashScope: {
|
||||
baseUrl: 'https://example.invalid',
|
||||
apiKey: '',
|
||||
imageModel: 'test-image-model',
|
||||
requestTimeoutMs: 1000,
|
||||
},
|
||||
smsAuth: {
|
||||
enabled: true,
|
||||
provider: 'mock',
|
||||
endpoint: 'dypnsapi.aliyuncs.com',
|
||||
accessKeyId: '',
|
||||
accessKeySecret: '',
|
||||
signName: 'Test Sign',
|
||||
templateCode: '100001',
|
||||
templateParamKey: 'code',
|
||||
countryCode: '86',
|
||||
schemeName: '',
|
||||
codeLength: 6,
|
||||
codeType: 1,
|
||||
validTimeSeconds: 300,
|
||||
intervalSeconds: 60,
|
||||
duplicatePolicy: 1,
|
||||
caseAuthPolicy: 1,
|
||||
returnVerifyCode: false,
|
||||
mockVerifyCode: '123456',
|
||||
maxSendPerPhonePerDay: 20,
|
||||
maxSendPerIpPerHour: 30,
|
||||
maxVerifyFailuresPerPhonePerHour: 12,
|
||||
maxVerifyFailuresPerIpPerHour: 24,
|
||||
captchaTtlSeconds: 180,
|
||||
captchaTriggerVerifyFailuresPerPhone: 3,
|
||||
captchaTriggerVerifyFailuresPerIp: 5,
|
||||
blockPhoneFailureThreshold: 6,
|
||||
blockIpFailureThreshold: 10,
|
||||
blockPhoneDurationMinutes: 30,
|
||||
blockIpDurationMinutes: 30,
|
||||
},
|
||||
wechatAuth: {
|
||||
enabled: true,
|
||||
provider: 'mock',
|
||||
appId: '',
|
||||
appSecret: '',
|
||||
authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect',
|
||||
accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token',
|
||||
userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo',
|
||||
callbackPath: '/api/auth/wechat/callback',
|
||||
defaultRedirectPath: '/',
|
||||
mockUserId: 'mock_wechat_user',
|
||||
mockUnionId: 'mock_wechat_union',
|
||||
mockDisplayName: '微信旅人',
|
||||
mockAvatarUrl: '',
|
||||
},
|
||||
authSession: {
|
||||
accessCookieName: 'genarrative_access_session',
|
||||
accessCookieTtlSeconds: 7200,
|
||||
accessCookieSecure: false,
|
||||
accessCookieSameSite: 'Lax',
|
||||
accessCookiePath: '/',
|
||||
refreshCookieName: 'genarrative_refresh_session',
|
||||
refreshSessionTtlDays: 30,
|
||||
refreshCookieSecure: false,
|
||||
refreshCookieSameSite: 'Lax',
|
||||
refreshCookiePath: '/api/auth',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function withTestServer<T>(
|
||||
testName: string,
|
||||
run: (options: { baseUrl: string }) => Promise<T>,
|
||||
) {
|
||||
const context = await createAppContext(createTestConfig(testName));
|
||||
const app = createApp(context);
|
||||
const server = await new Promise<import('node:http').Server>((resolve) => {
|
||||
const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer));
|
||||
});
|
||||
|
||||
try {
|
||||
const address = server.address() as AddressInfo;
|
||||
return await run({
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await context.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function authEntry(baseUrl: string, username: string, password: string) {
|
||||
const response = await httpRequest(`${baseUrl}/api/auth/entry`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
const payload = (await response.json()) as {
|
||||
token: string;
|
||||
user: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(payload.token);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function withBearer(token: string, init: TestRequestInit = {}) {
|
||||
return {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers ?? {}),
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
} satisfies TestRequestInit;
|
||||
}
|
||||
|
||||
async function putSnapshot(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
body: Record<string, unknown>,
|
||||
) {
|
||||
const response = await httpRequest(
|
||||
`${baseUrl}/api/runtime/save/snapshot`,
|
||||
withBearer(token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
test('rpg profile routes keep new and legacy dashboard compatibility', async () => {
|
||||
await withTestServer('profile-compat', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'rpg_profile_user', 'secret123');
|
||||
|
||||
await putSnapshot(baseUrl, entry.token, {
|
||||
gameState: {
|
||||
currentScene: 'Story',
|
||||
worldType: 'WUXIA',
|
||||
playerCharacter: {
|
||||
id: 'hero-profile',
|
||||
title: '试剑客',
|
||||
description: '赶路的人。',
|
||||
personality: '稳重',
|
||||
attributes: {
|
||||
strength: 8,
|
||||
},
|
||||
skills: [],
|
||||
},
|
||||
},
|
||||
bottomTab: 'adventure',
|
||||
currentStory: {
|
||||
text: '第一段记录',
|
||||
options: [],
|
||||
},
|
||||
savedAt: '2026-04-21T10:00:00.000Z',
|
||||
});
|
||||
|
||||
const runtimeResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/profile/dashboard`,
|
||||
withBearer(entry.token),
|
||||
);
|
||||
const runtimePayload = (await runtimeResponse.json()) as {
|
||||
walletBalance: number;
|
||||
playedWorldCount: number;
|
||||
};
|
||||
const legacyResponse = await httpRequest(
|
||||
`${baseUrl}/api/profile/dashboard`,
|
||||
withBearer(entry.token),
|
||||
);
|
||||
const legacyPayload = (await legacyResponse.json()) as typeof runtimePayload;
|
||||
|
||||
assert.equal(runtimeResponse.status, 200);
|
||||
assert.equal(legacyResponse.status, 200);
|
||||
assert.deepEqual(legacyPayload, runtimePayload);
|
||||
});
|
||||
});
|
||||
|
||||
test('rpg entry save routes keep list and resume archive compatibility', async () => {
|
||||
await withTestServer('save-archive-compat', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'rpg_save_user', 'secret123');
|
||||
|
||||
await putSnapshot(baseUrl, entry.token, {
|
||||
gameState: {
|
||||
currentScene: 'Story',
|
||||
worldType: 'CUSTOM',
|
||||
customWorldProfile: {
|
||||
id: 'world-archive-a',
|
||||
name: '裂潮边城',
|
||||
},
|
||||
playerCharacter: {
|
||||
id: 'hero-save',
|
||||
title: '归乡人',
|
||||
description: '带着旧信回城。',
|
||||
personality: '沉静',
|
||||
attributes: {
|
||||
spirit: 9,
|
||||
},
|
||||
skills: [],
|
||||
},
|
||||
playerCurrency: 42,
|
||||
},
|
||||
bottomTab: 'adventure',
|
||||
currentStory: {
|
||||
text: '旧灯塔还亮着。',
|
||||
options: [],
|
||||
},
|
||||
savedAt: '2026-04-21T10:05:00.000Z',
|
||||
});
|
||||
|
||||
const listRuntime = await httpRequest(
|
||||
`${baseUrl}/api/runtime/profile/save-archives`,
|
||||
withBearer(entry.token),
|
||||
);
|
||||
const listLegacy = await httpRequest(
|
||||
`${baseUrl}/api/profile/save-archives`,
|
||||
withBearer(entry.token),
|
||||
);
|
||||
const runtimePayload = (await listRuntime.json()) as {
|
||||
entries: Array<{ worldKey: string }>;
|
||||
};
|
||||
const legacyPayload = (await listLegacy.json()) as typeof runtimePayload;
|
||||
|
||||
assert.equal(listRuntime.status, 200);
|
||||
assert.equal(listLegacy.status, 200);
|
||||
assert.deepEqual(legacyPayload.entries, runtimePayload.entries);
|
||||
assert.equal(runtimePayload.entries.length, 1);
|
||||
|
||||
const worldKey = runtimePayload.entries[0]?.worldKey;
|
||||
assert.ok(worldKey);
|
||||
|
||||
const resumeRuntime = await httpRequest(
|
||||
`${baseUrl}/api/runtime/profile/save-archives/${encodeURIComponent(worldKey!)}`,
|
||||
withBearer(entry.token, {
|
||||
method: 'POST',
|
||||
}),
|
||||
);
|
||||
const resumeLegacy = await httpRequest(
|
||||
`${baseUrl}/api/profile/save-archives/${encodeURIComponent(worldKey!)}`,
|
||||
withBearer(entry.token, {
|
||||
method: 'POST',
|
||||
}),
|
||||
);
|
||||
const resumeRuntimePayload = (await resumeRuntime.json()) as {
|
||||
entry: { worldKey: string };
|
||||
snapshot: { gameState: { playerCurrency: number } };
|
||||
};
|
||||
const resumeLegacyPayload = (await resumeLegacy.json()) as typeof resumeRuntimePayload;
|
||||
|
||||
assert.equal(resumeRuntime.status, 200);
|
||||
assert.equal(resumeLegacy.status, 200);
|
||||
assert.deepEqual(resumeLegacyPayload.entry, resumeRuntimePayload.entry);
|
||||
assert.equal(
|
||||
resumeLegacyPayload.snapshot.bottomTab,
|
||||
resumeRuntimePayload.snapshot.bottomTab,
|
||||
);
|
||||
assert.equal(
|
||||
resumeLegacyPayload.snapshot.currentStory.text,
|
||||
resumeRuntimePayload.snapshot.currentStory.text,
|
||||
);
|
||||
assert.equal(resumeRuntimePayload.snapshot.gameState.playerCurrency, 42);
|
||||
assert.equal(resumeLegacyPayload.snapshot.gameState.playerCurrency, 42);
|
||||
});
|
||||
});
|
||||
|
||||
test('rpg world library routes expose gallery and library through new boundaries', async () => {
|
||||
await withTestServer('world-library-boundary', async ({ baseUrl }) => {
|
||||
const owner = await authEntry(baseUrl, 'rpg_world_owner', 'secret123');
|
||||
|
||||
const upsertResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-library/world-a`,
|
||||
withBearer(owner.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
profile: {
|
||||
name: '裂桥前线',
|
||||
subtitle: '雾潮压城',
|
||||
summary: '守桥与沉船商盟持续拉扯。',
|
||||
settingText: '一座被雾潮包住的边城。',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
attributeSchema: {
|
||||
slots: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(upsertResponse.status, 200);
|
||||
|
||||
const libraryResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-library`,
|
||||
withBearer(owner.token),
|
||||
);
|
||||
const libraryPayload = (await libraryResponse.json()) as {
|
||||
entries: Array<{ profileId: string }>;
|
||||
};
|
||||
assert.equal(libraryResponse.status, 200);
|
||||
assert.deepEqual(
|
||||
libraryPayload.entries.map((entry) => entry.profileId),
|
||||
['world-a'],
|
||||
);
|
||||
|
||||
const publishResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-library/world-a/publish`,
|
||||
withBearer(owner.token, {
|
||||
method: 'POST',
|
||||
}),
|
||||
);
|
||||
assert.equal(publishResponse.status, 200);
|
||||
|
||||
const galleryResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-gallery`,
|
||||
);
|
||||
const galleryPayload = (await galleryResponse.json()) as {
|
||||
entries: Array<{ ownerUserId: string; profileId: string }>;
|
||||
};
|
||||
assert.equal(galleryResponse.status, 200);
|
||||
assert.equal(galleryPayload.entries.length, 1);
|
||||
|
||||
const detailResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-gallery/${encodeURIComponent(galleryPayload.entries[0]!.ownerUserId)}/${encodeURIComponent(galleryPayload.entries[0]!.profileId)}`,
|
||||
);
|
||||
const detailPayload = (await detailResponse.json()) as {
|
||||
entry: {
|
||||
profileId: string;
|
||||
worldName: string;
|
||||
};
|
||||
};
|
||||
assert.equal(detailResponse.status, 200);
|
||||
assert.equal(detailPayload.entry.profileId, 'world-a');
|
||||
assert.equal(detailPayload.entry.worldName, '裂桥前线');
|
||||
});
|
||||
});
|
||||
|
||||
test('rpg runtime story routes resolve through the new route boundary', async () => {
|
||||
await withTestServer('runtime-story-boundary', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'rpg_story_user', 'secret123');
|
||||
|
||||
await putSnapshot(baseUrl, entry.token, {
|
||||
gameState: {
|
||||
worldType: 'WUXIA',
|
||||
playerCharacter: {
|
||||
id: 'hero-story',
|
||||
title: '试剑客',
|
||||
description: '站在桥口的人。',
|
||||
personality: '谨慎',
|
||||
attributes: {
|
||||
strength: 8,
|
||||
spirit: 6,
|
||||
},
|
||||
skills: [],
|
||||
},
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'test-scene',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: 'idle',
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc_merchant_01',
|
||||
npcName: '沈七',
|
||||
npcDescription: '腰间挂着药囊的行商',
|
||||
context: '受伤行商',
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 31,
|
||||
playerMaxHp: 40,
|
||||
playerMana: 9,
|
||||
playerMaxMana: 16,
|
||||
playerSkillCooldowns: {},
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 90,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {
|
||||
npc_merchant_01: {
|
||||
affinity: 46,
|
||||
chattedCount: 0,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
},
|
||||
bottomTab: 'adventure',
|
||||
currentStory: {
|
||||
text: '巡路人看着你,像在等一句开口。',
|
||||
options: [],
|
||||
},
|
||||
});
|
||||
|
||||
const stateResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/story/state/runtime-main`,
|
||||
withBearer(entry.token),
|
||||
);
|
||||
const statePayload = (await stateResponse.json()) as {
|
||||
viewModel: {
|
||||
availableOptions: Array<{ functionId: string }>;
|
||||
};
|
||||
};
|
||||
assert.equal(stateResponse.status, 200);
|
||||
assert.ok(
|
||||
statePayload.viewModel.availableOptions.some(
|
||||
(option) => option.functionId === 'npc_chat',
|
||||
),
|
||||
);
|
||||
|
||||
const actionResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/story/actions/resolve`,
|
||||
withBearer(entry.token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 0,
|
||||
action: {
|
||||
type: 'story_choice',
|
||||
functionId: 'npc_chat',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const actionPayload = (await actionResponse.json()) as {
|
||||
serverVersion: number;
|
||||
viewModel: {
|
||||
encounter: {
|
||||
affinity: number;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(actionResponse.status, 200);
|
||||
assert.equal(actionPayload.serverVersion, 1);
|
||||
assert.equal(actionPayload.viewModel.encounter?.affinity, 52);
|
||||
});
|
||||
});
|
||||
@@ -1,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;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
269
server-node/src/services/RpgWorldPreviewCompiler.test.ts
Normal file
269
server-node/src/services/RpgWorldPreviewCompiler.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
65
server-node/src/services/RpgWorldPreviewCompiler.ts
Normal file
65
server-node/src/services/RpgWorldPreviewCompiler.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
46
server-node/src/services/RpgWorldWorkCoverResolver.ts
Normal file
46
server-node/src/services/RpgWorldWorkCoverResolver.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
301
server-node/src/services/RpgWorldWorkSummaryAssembler.ts
Normal file
301
server-node/src/services/RpgWorldWorkSummaryAssembler.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
44
server-node/src/services/RpgWorldWorkSummaryService.ts
Normal file
44
server-node/src/services/RpgWorldWorkSummaryService.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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'>;
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
260
server-node/src/services/customWorldAgentActionRegistry.test.ts
Normal file
260
server-node/src/services/customWorldAgentActionRegistry.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
403
server-node/src/services/customWorldAgentActionRegistry.ts
Normal file
403
server-node/src/services/customWorldAgentActionRegistry.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user