Persist custom world asset configs in runtime snapshots
This commit is contained in:
@@ -3131,6 +3131,196 @@ test('runtime snapshot persistence accepts null currentStory payloads', async ()
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime snapshot persistence syncs custom world asset configs into snapshot and profile storage', async () => {
|
||||
await withTestServer(
|
||||
'persistence-custom-world-assets',
|
||||
async ({ baseUrl, context }) => {
|
||||
const entry = await authEntry(
|
||||
baseUrl,
|
||||
'playercustomworldassets',
|
||||
'secret123',
|
||||
);
|
||||
|
||||
const saveResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/save/snapshot`,
|
||||
withBearer(entry.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
gameState: {
|
||||
currentScene: 'Story',
|
||||
worldType: 'CUSTOM',
|
||||
playerCharacter: {
|
||||
id: 'playable-asset-role',
|
||||
portrait:
|
||||
'/generated-characters/playable-asset-role/visual/visual-1/master.png',
|
||||
generatedVisualAssetId: 'visual-1',
|
||||
generatedAnimationSetId: 'animation-set-1',
|
||||
animationMap: {
|
||||
idle: {
|
||||
folder: 'idle',
|
||||
prefix: 'Idle',
|
||||
frames: 4,
|
||||
basePath:
|
||||
'/generated-animations/playable-asset-role/animation-set-1/idle',
|
||||
},
|
||||
},
|
||||
},
|
||||
currentScenePreset: {
|
||||
id: 'custom-scene-landmark-1',
|
||||
name: '潮声断桥',
|
||||
description: '旧桥横在潮雾之上。',
|
||||
imageSrc:
|
||||
'/generated-custom-world-scenes/cw-profile-asset/landmark-1/scene.png',
|
||||
},
|
||||
customWorldProfile: {
|
||||
id: 'cw-profile-asset',
|
||||
name: '潮雾裂港',
|
||||
subtitle: '退潮时响起旧讯号',
|
||||
summary: '雾与潮共同切开港湾边境。',
|
||||
tone: '冷潮压城,旧案未散',
|
||||
playerGoal: '追出失落讯标的去向',
|
||||
settingText: '一座被潮雾与旧讯号撕开的港湾世界。',
|
||||
templateWorldType: 'WUXIA',
|
||||
compatibilityTemplateWorldType: 'WUXIA',
|
||||
majorFactions: ['潮关守备'],
|
||||
coreConflicts: ['讯标争夺'],
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-asset-role',
|
||||
name: '沈潮',
|
||||
title: '归港行者',
|
||||
role: '可扮演角色',
|
||||
description: '总盯着退潮后的暗线。',
|
||||
backstory: '他从失讯后的航路里活着回来。',
|
||||
personality: '谨慎克制',
|
||||
motivation: '找回失落讯标',
|
||||
combatStyle: '借潮势游走压制',
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['识得旧港规矩'],
|
||||
tags: ['潮港', '追迹'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '他像一直在等潮声回信。',
|
||||
chapters: [],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
camp: {
|
||||
name: '归潮居',
|
||||
description: '退潮后还能落脚的旧屋。',
|
||||
dangerLevel: 'low',
|
||||
},
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '潮声断桥',
|
||||
description: '旧桥横在潮雾之上。',
|
||||
dangerLevel: 'medium',
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
attributeSchema: {
|
||||
slots: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
bottomTab: 'adventure',
|
||||
currentStory: {
|
||||
text: '潮声还在桥下回荡。',
|
||||
options: [],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const savePayload = (await saveResponse.json()) as {
|
||||
gameState: {
|
||||
customWorldProfile: {
|
||||
playableNpcs: Array<{
|
||||
imageSrc?: string;
|
||||
generatedVisualAssetId?: string;
|
||||
generatedAnimationSetId?: string;
|
||||
animationMap?: Record<string, { basePath?: string }>;
|
||||
}>;
|
||||
landmarks: Array<{
|
||||
imageSrc?: string;
|
||||
}>;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(saveResponse.status, 200);
|
||||
assert.equal(
|
||||
savePayload.gameState.customWorldProfile?.playableNpcs[0]?.imageSrc,
|
||||
'/generated-characters/playable-asset-role/visual/visual-1/master.png',
|
||||
);
|
||||
assert.equal(
|
||||
savePayload.gameState.customWorldProfile?.playableNpcs[0]
|
||||
?.generatedVisualAssetId,
|
||||
'visual-1',
|
||||
);
|
||||
assert.equal(
|
||||
savePayload.gameState.customWorldProfile?.playableNpcs[0]
|
||||
?.generatedAnimationSetId,
|
||||
'animation-set-1',
|
||||
);
|
||||
assert.equal(
|
||||
savePayload.gameState.customWorldProfile?.playableNpcs[0]?.animationMap
|
||||
?.idle?.basePath,
|
||||
'/generated-animations/playable-asset-role/animation-set-1/idle',
|
||||
);
|
||||
assert.equal(
|
||||
savePayload.gameState.customWorldProfile?.landmarks[0]?.imageSrc,
|
||||
'/generated-custom-world-scenes/cw-profile-asset/landmark-1/scene.png',
|
||||
);
|
||||
|
||||
const persistedRows = await context.db.query<{
|
||||
payload: {
|
||||
playableNpcs?: Array<{
|
||||
imageSrc?: string;
|
||||
generatedVisualAssetId?: string;
|
||||
generatedAnimationSetId?: string;
|
||||
animationMap?: Record<string, { basePath?: string }>;
|
||||
}>;
|
||||
landmarks?: Array<{
|
||||
imageSrc?: string;
|
||||
}>;
|
||||
};
|
||||
}>(
|
||||
`SELECT payload_json AS payload
|
||||
FROM custom_world_profiles
|
||||
WHERE user_id = $1
|
||||
AND profile_id = $2`,
|
||||
[entry.user.id, 'cw-profile-asset'],
|
||||
);
|
||||
|
||||
assert.equal(persistedRows.rows.length, 1);
|
||||
assert.equal(
|
||||
persistedRows.rows[0]?.payload?.playableNpcs?.[0]?.imageSrc,
|
||||
'/generated-characters/playable-asset-role/visual/visual-1/master.png',
|
||||
);
|
||||
assert.equal(
|
||||
persistedRows.rows[0]?.payload?.playableNpcs?.[0]
|
||||
?.generatedAnimationSetId,
|
||||
'animation-set-1',
|
||||
);
|
||||
assert.equal(
|
||||
persistedRows.rows[0]?.payload?.playableNpcs?.[0]?.animationMap?.idle
|
||||
?.basePath,
|
||||
'/generated-animations/playable-asset-role/animation-set-1/idle',
|
||||
);
|
||||
assert.equal(
|
||||
persistedRows.rows[0]?.payload?.landmarks?.[0]?.imageSrc,
|
||||
'/generated-custom-world-scenes/cw-profile-asset/landmark-1/scene.png',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('runtime snapshot persistence returns hydrated snapshots for frontend restore flows', async () => {
|
||||
await withTestServer('persistence-hydrated-snapshot', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(
|
||||
|
||||
@@ -238,7 +238,9 @@ test('character visual generation converts public reference images into data url
|
||||
assert.match(content[0]?.text ?? '', /右向斜侧身/u);
|
||||
assert.match(content[0]?.text ?? '', /纯绿色绿幕/u);
|
||||
assert.match(content[0]?.text ?? '', /1:1 正方形画幅|1:1 正方形画布/u);
|
||||
assert.match(content[0]?.text ?? '', /2 到 3 头身/u);
|
||||
assert.match(content[0]?.text ?? '', /2 到 2\.5 头身|2 到 3 头身/u);
|
||||
assert.match(content[0]?.text ?? '', /躯干与四肢短而紧凑/u);
|
||||
assert.match(content[0]?.text ?? '', /深色粗轮廓配合清晰大色块/u);
|
||||
assert.match(content[0]?.text ?? '', /不要把主题词自动扩写成背景建筑/u);
|
||||
assert.doesNotMatch(content[0]?.text ?? '', /水母国王/u);
|
||||
assert.match(content[1]?.image ?? '', /^data:image\/png;base64,/u);
|
||||
@@ -287,7 +289,9 @@ test('character prompt bundle generation falls back to local defaults when llm c
|
||||
assert.match(payload.visualPromptText, /港口向导/u);
|
||||
assert.match(payload.visualPromptText, /右向斜侧身/u);
|
||||
assert.match(payload.visualPromptText, /纯绿色绿幕/u);
|
||||
assert.match(payload.visualPromptText, /2 到 3 头身/u);
|
||||
assert.match(payload.visualPromptText, /2 到 2\.5 头身/u);
|
||||
assert.match(payload.visualPromptText, /躯干与四肢短而紧凑/u);
|
||||
assert.match(payload.visualPromptText, /深色粗轮廓配合清晰大色块/u);
|
||||
assert.match(payload.animationPromptText, /动作/u);
|
||||
assert.match(payload.scenePromptText, /场景/u);
|
||||
});
|
||||
@@ -431,6 +435,104 @@ test('character workflow cache skips rewriting unchanged payloads', async () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('character workflow cache stays isolated for different character ids', async () => {
|
||||
const tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'genarrative-character-workflow-cache-isolated-'),
|
||||
);
|
||||
|
||||
await withAssetRouteServer(
|
||||
createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'),
|
||||
async (assetBaseUrl) => {
|
||||
const firstPayload = {
|
||||
characterId: '巡海夜灯',
|
||||
visualPromptText: '夜灯守望者',
|
||||
animationPromptText: '短刀前压,动作克制',
|
||||
visualDrafts: [
|
||||
{
|
||||
id: 'draft-1',
|
||||
label: '候选 1',
|
||||
imageSrc: '/generated-character-drafts/sea-lantern/draft-1.png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
],
|
||||
selectedVisualDraftId: 'draft-1',
|
||||
selectedAnimation: 'idle',
|
||||
};
|
||||
const secondPayload = {
|
||||
characterId: '雾港引路人',
|
||||
visualPromptText: '雾港引路者',
|
||||
animationPromptText: '提灯侧身,站姿稳定',
|
||||
visualDrafts: [
|
||||
{
|
||||
id: 'draft-2',
|
||||
label: '候选 2',
|
||||
imageSrc: '/generated-character-drafts/fog-guide/draft-2.png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
],
|
||||
selectedVisualDraftId: 'draft-2',
|
||||
selectedAnimation: 'run',
|
||||
};
|
||||
|
||||
const firstSaveResponse = await fetch(
|
||||
`${assetBaseUrl}/api/assets/character-workflow-cache`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(firstPayload),
|
||||
},
|
||||
);
|
||||
assert.equal(firstSaveResponse.status, 200);
|
||||
|
||||
const secondSaveResponse = await fetch(
|
||||
`${assetBaseUrl}/api/assets/character-workflow-cache`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(secondPayload),
|
||||
},
|
||||
);
|
||||
assert.equal(secondSaveResponse.status, 200);
|
||||
|
||||
const firstReadResponse = await fetch(
|
||||
`${assetBaseUrl}/api/assets/character-workflow-cache/${encodeURIComponent(firstPayload.characterId)}`,
|
||||
);
|
||||
assert.equal(firstReadResponse.status, 200);
|
||||
const firstReadPayload = (await firstReadResponse.json()) as {
|
||||
cache: {
|
||||
characterId: string;
|
||||
visualPromptText: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const secondReadResponse = await fetch(
|
||||
`${assetBaseUrl}/api/assets/character-workflow-cache/${encodeURIComponent(secondPayload.characterId)}`,
|
||||
);
|
||||
assert.equal(secondReadResponse.status, 200);
|
||||
const secondReadPayload = (await secondReadResponse.json()) as {
|
||||
cache: {
|
||||
characterId: string;
|
||||
visualPromptText: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
assert.equal(firstReadPayload.cache?.characterId, firstPayload.characterId);
|
||||
assert.equal(firstReadPayload.cache?.visualPromptText, firstPayload.visualPromptText);
|
||||
assert.equal(secondReadPayload.cache?.characterId, secondPayload.characterId);
|
||||
assert.equal(
|
||||
secondReadPayload.cache?.visualPromptText,
|
||||
secondPayload.visualPromptText,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('character animation publish returns frame dimensions in animation map', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-animation-publish-'));
|
||||
|
||||
@@ -755,6 +857,103 @@ test('character animation non-loop image-to-video uses first and last master fra
|
||||
);
|
||||
});
|
||||
|
||||
test('character animation die image-to-video does not send a last frame reference', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-kf2v-die-'));
|
||||
const publicDir = path.join(tempRoot, 'public');
|
||||
fs.mkdirSync(publicDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER);
|
||||
|
||||
let videoSynthesisPayloadText = '';
|
||||
|
||||
await withHttpServer(
|
||||
(dashScopeBaseUrl) => async (req, res) => {
|
||||
const url = new URL(req.url || '/', dashScopeBaseUrl);
|
||||
|
||||
if (
|
||||
req.method === 'POST' &&
|
||||
url.pathname === '/api/v1/services/aigc/image2video/video-synthesis'
|
||||
) {
|
||||
videoSynthesisPayloadText = (await readRequestBody(req)).toString('utf8');
|
||||
sendJson(res, {
|
||||
output: {
|
||||
task_id: 'video-task-kf2v-die-1',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/v1/tasks/video-task-kf2v-die-1') {
|
||||
sendJson(res, {
|
||||
output: {
|
||||
task_status: 'SUCCEEDED',
|
||||
video_url: `${dashScopeBaseUrl}/downloads/preview.mp4`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/downloads/preview.mp4') {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'video/mp4');
|
||||
res.end(MP4_BUFFER);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.end('not found');
|
||||
},
|
||||
async (dashScopeBaseUrl) => {
|
||||
const config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`);
|
||||
await withAssetRouteServer(config, async (assetBaseUrl) => {
|
||||
const response = await fetch(
|
||||
`${assetBaseUrl}/api/assets/character-animation/generate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
characterId: 'harbor-guide',
|
||||
strategy: 'image-to-video',
|
||||
animation: 'die',
|
||||
promptText: '倒地后停在终止姿态',
|
||||
characterBriefText: '旧港守望者',
|
||||
visualSource: '/visual.png',
|
||||
referenceImageDataUrls: [],
|
||||
referenceVideoDataUrls: [],
|
||||
frameCount: 8,
|
||||
fps: 8,
|
||||
durationSeconds: 4,
|
||||
loop: false,
|
||||
useChromaKey: true,
|
||||
resolution: '720P',
|
||||
imageSequenceModel: 'wan2.7-image-pro',
|
||||
videoModel: 'wan2.7-i2v',
|
||||
referenceVideoModel: 'wan2.7-r2v',
|
||||
motionTransferModel: 'wan2.2-animate-move',
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const videoPayload = JSON.parse(videoSynthesisPayloadText) as {
|
||||
model: string;
|
||||
input: {
|
||||
first_frame_url?: string;
|
||||
last_frame_url?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
};
|
||||
assert.equal(videoPayload.model, 'wan2.2-kf2v-flash');
|
||||
assert.match(videoPayload.input.first_frame_url ?? '', /^data:image\/png;base64,/u);
|
||||
assert.equal(videoPayload.input.last_frame_url, undefined);
|
||||
assert.match(videoPayload.input.prompt ?? '', /尾帧停在死亡结束姿态/u);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('character animation loop image-to-video uses wan2.6-i2v-flash with img_url only', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-i2v-loop-'));
|
||||
const publicDir = path.join(tempRoot, 'public');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import http, {
|
||||
type IncomingMessage,
|
||||
@@ -349,7 +350,7 @@ function buildFallbackCharacterPromptBundle(params: {
|
||||
return {
|
||||
visualPromptText: [
|
||||
`${characterAnchor},${roleAnchor}。`,
|
||||
'单人全身,2D 横版 RPG 角色标准设定图,1:1 正方形画幅,头身比控制在 2 到 3 头身,右向斜侧身站立,身体整体朝右但保留少量正面信息,脚底完整可见,服装、发型、武器和轮廓稳定清楚。',
|
||||
'单人全身,2D 横版 RPG 角色标准设定图,1:1 正方形画幅,头身比控制在 2 到 2.5 头身,偏大头身,靠头部、发型、服装、配饰表现角色记忆点,躯干与四肢短而紧凑,五官简化,深色粗轮廓配合清晰大色块,右向斜侧身站立,身体整体朝右但保留少量正面信息,服装、发型、轮廓稳定清楚。',
|
||||
`外观气质围绕:${descriptionAnchor}。`,
|
||||
combatAnchor ? `战斗识别点:${combatAnchor}。` : '',
|
||||
tagAnchor,
|
||||
@@ -359,7 +360,7 @@ function buildFallbackCharacterPromptBundle(params: {
|
||||
.join(' '),
|
||||
animationPromptText: [
|
||||
`${characterAnchor}的核心动作试片。`,
|
||||
'保持同一角色的服装、发型、武器和体型一致,镜头稳定,侧身朝右,动作连贯。',
|
||||
'保持同一角色的服装、发型、体型一致,镜头稳定,侧身朝右,动作连贯。',
|
||||
combatAnchor ? `动作气质参考:${combatAnchor}。` : '',
|
||||
params.personality ? `角色气质补充:${params.personality}。` : '',
|
||||
'发力起手明确,过程干净,收招利落,避免漂移和变形。',
|
||||
@@ -560,11 +561,17 @@ function getJobRecordPath(
|
||||
}
|
||||
|
||||
function getCharacterWorkflowCachePath(rootDir: string, characterId: string) {
|
||||
const readableSegment = sanitizePathSegment(characterId);
|
||||
const characterCacheKey = createHash('sha256')
|
||||
.update(characterId, 'utf8')
|
||||
.digest('hex')
|
||||
.slice(0, 24);
|
||||
|
||||
return path.resolve(
|
||||
rootDir,
|
||||
'public',
|
||||
'generated-character-drafts',
|
||||
sanitizePathSegment(characterId),
|
||||
`${readableSegment}-${characterCacheKey}`,
|
||||
'workflow-cache.json',
|
||||
);
|
||||
}
|
||||
@@ -1163,7 +1170,9 @@ function buildNpcAnimationPrompt(options: {
|
||||
const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140);
|
||||
const loopRule = options.loop
|
||||
? '这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。'
|
||||
: '这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。';
|
||||
: options.animation === 'die'
|
||||
? '这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。'
|
||||
: '这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。';
|
||||
|
||||
if (options.actionTemplateId) {
|
||||
return [
|
||||
@@ -1963,9 +1972,10 @@ async function handleGenerateCharacterAnimation(
|
||||
`${characterId}-${animation}-visual`,
|
||||
await resolveMediaSourcePayload(rootDir, visualSource),
|
||||
);
|
||||
const resolvedLastFrameSource = !loop
|
||||
? lastFrameImageDataUrl || visualSource
|
||||
: '';
|
||||
const resolvedLastFrameSource =
|
||||
!loop && animation !== 'die'
|
||||
? lastFrameImageDataUrl || visualSource
|
||||
: '';
|
||||
const lastFrameRef = resolvedLastFrameSource
|
||||
? isKf2vFlash
|
||||
? await resolveMediaSourceAsDataUrl(rootDir, resolvedLastFrameSource)
|
||||
@@ -2730,7 +2740,9 @@ async function handleGetCharacterWorkflowCache(
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
cache:
|
||||
isRecordValue(cache) && typeof cache.characterId === 'string'
|
||||
isRecordValue(cache) &&
|
||||
typeof cache.characterId === 'string' &&
|
||||
cache.characterId === characterId
|
||||
? cache
|
||||
: null,
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ export function createStoryActionRoutes(context: AppContext) {
|
||||
response,
|
||||
await resolveRuntimeStoryAction({
|
||||
runtimeRepository: context.runtimeRepository,
|
||||
llmClient: context.llmClient,
|
||||
userId: request.userId!,
|
||||
request: payload,
|
||||
}),
|
||||
|
||||
@@ -2,12 +2,19 @@ import type {
|
||||
RuntimeBattlePresentation,
|
||||
RuntimeStoryActionRequest,
|
||||
RuntimeStoryActionResponse,
|
||||
RuntimeStoryOptionView,
|
||||
RuntimeStoryPatch,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import { conflict, invalidRequest } from '../../errors.js';
|
||||
import type { RuntimeRepositoryPort } from '../../repositories/runtimeRepository.js';
|
||||
import type { UpstreamLlmClient } from '../../services/llmClient.js';
|
||||
import {
|
||||
buildStrictNpcChatDialoguePrompt,
|
||||
NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
|
||||
} from '../ai/chatPromptBuilders.js';
|
||||
import { generateNextStoryFromOrchestrator } from '../ai/storyOrchestrator.js';
|
||||
import { resolveCombatAction } from '../combat/combatResolutionService.js';
|
||||
import { resolveInventoryStoryAction, isSupportedInventoryStoryFunctionId } from '../inventory/inventoryStoryActionService.js';
|
||||
import { isSupportedInventoryStoryFunctionId,resolveInventoryStoryAction } from '../inventory/inventoryStoryActionService.js';
|
||||
import {
|
||||
ensureNpcInventorySessionState,
|
||||
isSupportedNpcInventoryStoryFunctionId,
|
||||
@@ -21,12 +28,15 @@ import {
|
||||
isSupportedQuestStoryFunctionId,
|
||||
resolveQuestStoryAction,
|
||||
} from '../quest/questStoryActionService.js';
|
||||
import {
|
||||
hydrateSavedSnapshot,
|
||||
normalizeSavedSnapshotPayload,
|
||||
} from '../runtime/runtimeSnapshotHydration.js';
|
||||
import {
|
||||
isSupportedTreasureStoryFunctionId,
|
||||
resolveTreasureStoryAction,
|
||||
} from '../runtime-item/treasureStoryActionService.js';
|
||||
import {
|
||||
TASK6_DEFERRED_FUNCTION_IDS,
|
||||
appendStoryHistory,
|
||||
buildAvailableOptions,
|
||||
buildLegacyCurrentStory,
|
||||
@@ -37,14 +47,11 @@ import {
|
||||
isStoryFunctionId,
|
||||
isTask5FunctionId,
|
||||
loadRuntimeSession,
|
||||
type RuntimeSession,
|
||||
setEncounterNpcState,
|
||||
syncRawGameState,
|
||||
type RuntimeSession,
|
||||
TASK6_DEFERRED_FUNCTION_IDS,
|
||||
} from './runtimeSession.js';
|
||||
import {
|
||||
hydrateSavedSnapshot,
|
||||
normalizeSavedSnapshotPayload,
|
||||
} from '../runtime/runtimeSnapshotHydration.js';
|
||||
|
||||
type StoryResolution = {
|
||||
actionText: string;
|
||||
@@ -55,6 +62,36 @@ type StoryResolution = {
|
||||
toast?: string | null;
|
||||
};
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type StoryOptionLike = {
|
||||
functionId: string;
|
||||
actionText: string;
|
||||
};
|
||||
|
||||
type GeneratedStoryPayload = {
|
||||
storyText: string;
|
||||
historyResultText: string;
|
||||
presentationOptions: RuntimeStoryOptionView[];
|
||||
savedCurrentStory: JsonRecord;
|
||||
};
|
||||
|
||||
const CONTINUE_ADVENTURE_OPTION = {
|
||||
functionId: 'story_continue_adventure',
|
||||
actionText: '继续冒险',
|
||||
detailText: '展开刚刚已经准备好的后续选项。',
|
||||
scope: 'story',
|
||||
} satisfies RuntimeStoryOptionView;
|
||||
|
||||
const DEFAULT_STORY_OPTION_VISUALS = {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
} as const;
|
||||
|
||||
function resolveActionText(defaultText: string, request: RuntimeStoryActionRequest) {
|
||||
const payload = request.action.payload;
|
||||
const optionText =
|
||||
@@ -65,6 +102,354 @@ function resolveActionText(defaultText: string, request: RuntimeStoryActionReque
|
||||
return optionText || defaultText;
|
||||
}
|
||||
|
||||
function isObject(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 readArray(value: unknown) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function buildStoryOptionInteraction(
|
||||
session: RuntimeSession,
|
||||
option: RuntimeStoryOptionView,
|
||||
) {
|
||||
const encounter = session.currentEncounter;
|
||||
|
||||
if (encounter?.kind === 'npc') {
|
||||
const npcId = encounter.id || encounter.npcName;
|
||||
const npcActionMap: Record<string, JsonRecord> = {
|
||||
npc_chat: { kind: 'npc', npcId, action: 'chat' },
|
||||
npc_help: { kind: 'npc', npcId, action: 'help' },
|
||||
npc_fight: { kind: 'npc', npcId, action: 'fight' },
|
||||
npc_leave: { kind: 'npc', npcId, action: 'leave' },
|
||||
npc_recruit: { kind: 'npc', npcId, action: 'recruit' },
|
||||
npc_spar: { kind: 'npc', npcId, action: 'spar' },
|
||||
npc_trade: { kind: 'npc', npcId, action: 'trade' },
|
||||
npc_gift: { kind: 'npc', npcId, action: 'gift' },
|
||||
npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' },
|
||||
npc_quest_turn_in: { kind: 'npc', npcId, action: 'quest_turn_in' },
|
||||
};
|
||||
|
||||
return npcActionMap[option.functionId];
|
||||
}
|
||||
|
||||
if (encounter?.kind === 'treasure') {
|
||||
const treasureActionMap: Record<string, JsonRecord> = {
|
||||
treasure_secure: { kind: 'treasure', action: 'secure' },
|
||||
treasure_inspect: { kind: 'treasure', action: 'inspect' },
|
||||
treasure_leave: { kind: 'treasure', action: 'leave' },
|
||||
};
|
||||
|
||||
return treasureActionMap[option.functionId];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildStoryOptionFromRuntimeOption(
|
||||
session: RuntimeSession,
|
||||
option: RuntimeStoryOptionView,
|
||||
) {
|
||||
const detailParts = [option.detailText, option.disabled ? option.reason : null]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return {
|
||||
functionId: option.functionId,
|
||||
actionText: option.actionText,
|
||||
text: option.actionText,
|
||||
detailText: detailParts || undefined,
|
||||
visuals: DEFAULT_STORY_OPTION_VISUALS,
|
||||
interaction: buildStoryOptionInteraction(session, option),
|
||||
} satisfies JsonRecord;
|
||||
}
|
||||
|
||||
function buildStoryOptionsFromRuntimeOptions(
|
||||
session: RuntimeSession,
|
||||
options: RuntimeStoryOptionView[],
|
||||
) {
|
||||
return options
|
||||
.filter((option) => !option.disabled)
|
||||
.map((option) => buildStoryOptionFromRuntimeOption(session, option));
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
}
|
||||
|
||||
function normalizeDialogueSpeakerName(rawSpeakerName: string) {
|
||||
return rawSpeakerName
|
||||
.trim()
|
||||
.replace(
|
||||
/^[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+/u,
|
||||
'',
|
||||
)
|
||||
.replace(
|
||||
/[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+$/u,
|
||||
'',
|
||||
)
|
||||
.replace(/^(?:\u540c\u4f34|\u961f\u53cb)\s*/u, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseDialogueTurns(text: string, npcName: string) {
|
||||
const turns: JsonRecord[] = [];
|
||||
const dialogueColonPattern = '(?:\\uFF1A|:)';
|
||||
const playerPrefixPattern = new RegExp(
|
||||
'^(?:\\\\u4f60|\\\\u73a9\\\\u5bb6|\\\\u4e3b\\\\u89d2)\\\\s*' +
|
||||
dialogueColonPattern +
|
||||
'\\\\s*(.+)$',
|
||||
'u',
|
||||
);
|
||||
const npcPrefixPattern = new RegExp(
|
||||
'^' +
|
||||
escapeRegExp(npcName) +
|
||||
'\\\\s*' +
|
||||
dialogueColonPattern +
|
||||
'\\\\s*(.+)$',
|
||||
'u',
|
||||
);
|
||||
const namedSpeakerPattern = new RegExp(
|
||||
'^(.{1,24}?)\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$',
|
||||
'u',
|
||||
);
|
||||
const lines = text
|
||||
.replace(/\r/g, '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
const playerMatch = line.match(playerPrefixPattern);
|
||||
const playerText = playerMatch?.[1]?.trim();
|
||||
if (playerText) {
|
||||
turns.push({ speaker: 'player', text: playerText });
|
||||
continue;
|
||||
}
|
||||
|
||||
const npcMatch = line.match(npcPrefixPattern);
|
||||
const npcText = npcMatch?.[1]?.trim();
|
||||
if (npcText) {
|
||||
turns.push({ speaker: 'npc', speakerName: npcName, text: npcText });
|
||||
continue;
|
||||
}
|
||||
|
||||
const namedSpeakerMatch = line.match(namedSpeakerPattern);
|
||||
if (namedSpeakerMatch?.[1] && namedSpeakerMatch[2]) {
|
||||
const speakerName = normalizeDialogueSpeakerName(namedSpeakerMatch[1]);
|
||||
const speakerText = namedSpeakerMatch[2].trim();
|
||||
|
||||
if (speakerName && speakerText) {
|
||||
turns.push({
|
||||
speaker: speakerName === npcName ? 'npc' : 'companion',
|
||||
speakerName,
|
||||
text: speakerText,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (line.startsWith('你:') || line.startsWith('你:')) {
|
||||
turns.push({ speaker: 'player', text: line.slice(2).trim() });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith(npcName + ':') || line.startsWith(npcName + ':')) {
|
||||
turns.push({
|
||||
speaker: 'npc',
|
||||
speakerName: npcName,
|
||||
text: line.slice(npcName.length + 1).trim(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastTurn = turns[turns.length - 1];
|
||||
if (lastTurn && typeof lastTurn.text === 'string') {
|
||||
lastTurn.text += line;
|
||||
}
|
||||
}
|
||||
|
||||
return turns.filter(
|
||||
(turn) => typeof turn.text === 'string' && turn.text.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function buildDialogueCurrentStory(params: {
|
||||
session: RuntimeSession;
|
||||
npcName: string;
|
||||
text: string;
|
||||
deferredOptions: RuntimeStoryOptionView[];
|
||||
}) {
|
||||
return {
|
||||
text: params.text,
|
||||
options: buildStoryOptionsFromRuntimeOptions(
|
||||
params.session,
|
||||
[CONTINUE_ADVENTURE_OPTION],
|
||||
),
|
||||
displayMode: 'dialogue',
|
||||
dialogue: parseDialogueTurns(params.text, params.npcName),
|
||||
streaming: false,
|
||||
deferredOptions: buildStoryOptionsFromRuntimeOptions(
|
||||
params.session,
|
||||
params.deferredOptions,
|
||||
),
|
||||
} satisfies JsonRecord;
|
||||
}
|
||||
|
||||
function buildStoryPromptContext(session: RuntimeSession, extras: JsonRecord = {}) {
|
||||
const scenePreset = isObject(session.rawGameState.currentScenePreset)
|
||||
? session.rawGameState.currentScenePreset
|
||||
: null;
|
||||
|
||||
return {
|
||||
sceneName:
|
||||
readString(scenePreset?.name) ||
|
||||
readString(session.rawGameState.currentScene) ||
|
||||
'当前区域',
|
||||
sceneDescription:
|
||||
readString(scenePreset?.description) ||
|
||||
readString(session.rawGameState.sceneDescription) ||
|
||||
'周围气氛仍在继续变化。',
|
||||
encounterName: session.currentEncounter?.npcName || null,
|
||||
encounterId: session.currentEncounter?.id || null,
|
||||
playerHp: session.playerHp,
|
||||
playerMaxHp: session.playerMaxHp,
|
||||
playerMana: session.playerMana,
|
||||
playerMaxMana: session.playerMaxMana,
|
||||
inBattle: session.inBattle,
|
||||
pendingSceneEncounter: false,
|
||||
...extras,
|
||||
} satisfies JsonRecord;
|
||||
}
|
||||
|
||||
function buildHistoryMoments(
|
||||
session: RuntimeSession,
|
||||
appendedEntries: Array<{ text: string; historyRole: 'action' | 'result' }>,
|
||||
) {
|
||||
return [
|
||||
...session.storyHistory.map((entry) => ({
|
||||
text: entry.text,
|
||||
historyRole: entry.historyRole,
|
||||
})),
|
||||
...appendedEntries,
|
||||
];
|
||||
}
|
||||
|
||||
function buildPromptOptions(options: RuntimeStoryOptionView[]) {
|
||||
return options
|
||||
.filter((option) => !option.disabled)
|
||||
.map((option) => ({
|
||||
functionId: option.functionId,
|
||||
actionText: option.actionText,
|
||||
text: option.actionText,
|
||||
}));
|
||||
}
|
||||
|
||||
function mergeGeneratedRuntimeOptions(
|
||||
baseOptions: RuntimeStoryOptionView[],
|
||||
generatedOptions: StoryOptionLike[],
|
||||
) {
|
||||
if (generatedOptions.length === 0) {
|
||||
return baseOptions;
|
||||
}
|
||||
|
||||
const buckets = new Map<string, RuntimeStoryOptionView[]>();
|
||||
baseOptions.forEach((option) => {
|
||||
const bucket = buckets.get(option.functionId) ?? [];
|
||||
bucket.push(option);
|
||||
buckets.set(option.functionId, bucket);
|
||||
});
|
||||
|
||||
const resolved: RuntimeStoryOptionView[] = [];
|
||||
const consumed = new Set<RuntimeStoryOptionView>();
|
||||
generatedOptions.forEach((option) => {
|
||||
const bucket = buckets.get(option.functionId);
|
||||
const matched = bucket?.shift();
|
||||
if (!matched) {
|
||||
return;
|
||||
}
|
||||
|
||||
consumed.add(matched);
|
||||
resolved.push({
|
||||
...matched,
|
||||
actionText: readString(option.actionText) || matched.actionText,
|
||||
});
|
||||
});
|
||||
|
||||
if (resolved.length === 0) {
|
||||
return baseOptions;
|
||||
}
|
||||
|
||||
const remaining = baseOptions.filter((option) => !consumed.has(option));
|
||||
return [...resolved, ...remaining];
|
||||
}
|
||||
|
||||
function buildOpeningCampChatContext(session: RuntimeSession) {
|
||||
const encounter = session.currentEncounter;
|
||||
if (!encounter || encounter.kind !== 'npc') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const rawEncounter = isObject(session.rawGameState.currentEncounter)
|
||||
? session.rawGameState.currentEncounter
|
||||
: null;
|
||||
if (readString(rawEncounter?.specialBehavior) !== 'camp_companion') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const npcState = getEncounterNpcState(session);
|
||||
if (!npcState || npcState.chattedCount > 2) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const openingActionText = `在营地与 ${encounter.npcName} 交换开场判断`;
|
||||
let openingDialogue = '';
|
||||
|
||||
for (let index = 0; index < session.storyHistory.length - 1; index += 1) {
|
||||
const entry = session.storyHistory[index];
|
||||
if (!entry || entry.historyRole !== 'action' || entry.text !== openingActionText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let nextIndex = index + 1; nextIndex < session.storyHistory.length; nextIndex += 1) {
|
||||
const nextEntry = session.storyHistory[nextIndex];
|
||||
if (!nextEntry) {
|
||||
continue;
|
||||
}
|
||||
if (nextEntry.historyRole === 'action') {
|
||||
break;
|
||||
}
|
||||
if (nextEntry.text.trim()) {
|
||||
openingDialogue = nextEntry.text.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (openingDialogue) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!openingDialogue) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const playerCharacter = isObject(session.rawGameState.playerCharacter)
|
||||
? session.rawGameState.playerCharacter
|
||||
: null;
|
||||
const playerName = readString(playerCharacter?.name) || '你';
|
||||
|
||||
return {
|
||||
openingCampBackground: `${playerName} 在营地里和 ${encounter.npcName} 终于真正把注意力放回彼此身上,周围暂时没有新的打扰。`,
|
||||
openingCampDialogue: openingDialogue,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStatusPatch(session: RuntimeSession) {
|
||||
return {
|
||||
type: 'status_changed',
|
||||
@@ -109,6 +494,128 @@ function buildFallbackStoryText(session: RuntimeSession) {
|
||||
return '当前故事状态已经同步到后端,接下来可以继续推进这一轮运行时动作。';
|
||||
}
|
||||
|
||||
async function generateNpcDialoguePayload(params: {
|
||||
llmClient: UpstreamLlmClient;
|
||||
session: RuntimeSession;
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
}): Promise<GeneratedStoryPayload | null> {
|
||||
const encounter = params.session.currentEncounter;
|
||||
const playerCharacter = isObject(params.session.rawGameState.playerCharacter)
|
||||
? params.session.rawGameState.playerCharacter
|
||||
: null;
|
||||
if (!encounter || encounter.kind !== 'npc' || !playerCharacter || !params.session.worldType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const history = buildHistoryMoments(params.session, [
|
||||
{ text: params.actionText, historyRole: 'action' },
|
||||
{ text: params.resultText, historyRole: 'result' },
|
||||
]);
|
||||
const availableOptions = buildAvailableOptions(params.session);
|
||||
const dialogueText = (
|
||||
await params.llmClient.requestMessageContent({
|
||||
systemPrompt: NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
|
||||
userPrompt: buildStrictNpcChatDialoguePrompt({
|
||||
worldType: params.session.worldType,
|
||||
character: playerCharacter,
|
||||
encounter: params.session.rawGameState.currentEncounter ?? {},
|
||||
monsters: readArray(params.session.rawGameState.sceneHostileNpcs).filter(isObject),
|
||||
history,
|
||||
context: buildStoryPromptContext(params.session, {
|
||||
...buildOpeningCampChatContext(params.session),
|
||||
}),
|
||||
topic: params.actionText,
|
||||
resultSummary: params.resultText,
|
||||
}),
|
||||
debugLabel: 'runtime.npc_chat.dialogue',
|
||||
})
|
||||
).trim();
|
||||
|
||||
const finalDialogueText = dialogueText || params.resultText;
|
||||
let deferredOptions = availableOptions;
|
||||
|
||||
try {
|
||||
const nextStory = await generateNextStoryFromOrchestrator(
|
||||
params.llmClient,
|
||||
params.session.worldType,
|
||||
playerCharacter,
|
||||
readArray(params.session.rawGameState.sceneHostileNpcs).filter(isObject),
|
||||
history,
|
||||
params.actionText,
|
||||
buildStoryPromptContext(params.session, {
|
||||
lastFunctionId: 'npc_chat',
|
||||
...buildOpeningCampChatContext(params.session),
|
||||
}),
|
||||
{
|
||||
availableOptions: buildPromptOptions(availableOptions),
|
||||
},
|
||||
);
|
||||
deferredOptions = mergeGeneratedRuntimeOptions(
|
||||
availableOptions,
|
||||
nextStory.options as StoryOptionLike[],
|
||||
);
|
||||
} catch {
|
||||
deferredOptions = availableOptions;
|
||||
}
|
||||
|
||||
return {
|
||||
storyText: finalDialogueText,
|
||||
historyResultText: finalDialogueText,
|
||||
presentationOptions: [CONTINUE_ADVENTURE_OPTION],
|
||||
savedCurrentStory: buildDialogueCurrentStory({
|
||||
session: params.session,
|
||||
npcName: encounter.npcName,
|
||||
text: finalDialogueText,
|
||||
deferredOptions,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function generateReasonedStoryPayload(params: {
|
||||
llmClient: UpstreamLlmClient;
|
||||
session: RuntimeSession;
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
}): Promise<GeneratedStoryPayload | null> {
|
||||
const playerCharacter = isObject(params.session.rawGameState.playerCharacter)
|
||||
? params.session.rawGameState.playerCharacter
|
||||
: null;
|
||||
if (!playerCharacter || !params.session.worldType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const availableOptions = buildAvailableOptions(params.session);
|
||||
const history = buildHistoryMoments(params.session, [
|
||||
{ text: params.actionText, historyRole: 'action' },
|
||||
{ text: params.resultText, historyRole: 'result' },
|
||||
]);
|
||||
const nextStory = await generateNextStoryFromOrchestrator(
|
||||
params.llmClient,
|
||||
params.session.worldType,
|
||||
playerCharacter,
|
||||
readArray(params.session.rawGameState.sceneHostileNpcs).filter(isObject),
|
||||
history,
|
||||
params.actionText,
|
||||
buildStoryPromptContext(params.session),
|
||||
{
|
||||
availableOptions: buildPromptOptions(availableOptions),
|
||||
},
|
||||
);
|
||||
const resolvedOptions = mergeGeneratedRuntimeOptions(
|
||||
availableOptions,
|
||||
nextStory.options as StoryOptionLike[],
|
||||
);
|
||||
const storyText = readString(nextStory.storyText) || params.resultText;
|
||||
|
||||
return {
|
||||
storyText,
|
||||
historyResultText: storyText,
|
||||
presentationOptions: resolvedOptions,
|
||||
savedCurrentStory: buildLegacyCurrentStory(storyText, resolvedOptions),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStoryFlowAction(
|
||||
session: RuntimeSession,
|
||||
functionId: string,
|
||||
@@ -212,6 +719,7 @@ function resolveStoryFlowAction(
|
||||
|
||||
export async function resolveRuntimeStoryAction(params: {
|
||||
runtimeRepository: RuntimeRepositoryPort;
|
||||
llmClient?: UpstreamLlmClient;
|
||||
userId: string;
|
||||
request: RuntimeStoryActionRequest;
|
||||
}) {
|
||||
@@ -295,16 +803,63 @@ export async function resolveRuntimeStoryAction(params: {
|
||||
battle: resolution.battle ?? null,
|
||||
});
|
||||
|
||||
const actionText = resolveActionText(resolution.actionText, params.request);
|
||||
const storyText = resolution.storyText ?? resolution.resultText;
|
||||
|
||||
appendStoryHistory(session, actionText, resolution.resultText);
|
||||
let actionText = resolveActionText(resolution.actionText, params.request);
|
||||
if (
|
||||
functionId === 'story_opening_camp_dialogue' &&
|
||||
session.currentEncounter?.kind === 'npc'
|
||||
) {
|
||||
actionText = `在营地与 ${session.currentEncounter.npcName} 交换开场判断`;
|
||||
}
|
||||
let storyText = resolution.storyText ?? resolution.resultText;
|
||||
let historyResultText = resolution.resultText;
|
||||
session.runtimeVersion += 1;
|
||||
session.sessionId = params.request.sessionId;
|
||||
|
||||
syncRawGameState(session);
|
||||
ensureNpcInventorySessionState(session);
|
||||
const options = buildAvailableOptions(session);
|
||||
let options = buildAvailableOptions(session);
|
||||
let savedCurrentStory: JsonRecord = buildLegacyCurrentStory(storyText, options);
|
||||
|
||||
if (
|
||||
params.llmClient &&
|
||||
(functionId === 'npc_chat' || functionId === 'story_opening_camp_dialogue')
|
||||
) {
|
||||
try {
|
||||
const generatedPayload = await generateNpcDialoguePayload({
|
||||
llmClient: params.llmClient,
|
||||
session,
|
||||
actionText,
|
||||
resultText: resolution.resultText,
|
||||
});
|
||||
if (generatedPayload) {
|
||||
storyText = generatedPayload.storyText;
|
||||
historyResultText = generatedPayload.historyResultText;
|
||||
options = generatedPayload.presentationOptions;
|
||||
savedCurrentStory = generatedPayload.savedCurrentStory;
|
||||
}
|
||||
} catch {
|
||||
savedCurrentStory = buildLegacyCurrentStory(storyText, options);
|
||||
}
|
||||
} else if (params.llmClient && isCombatFunctionId(functionId)) {
|
||||
try {
|
||||
const generatedPayload = await generateReasonedStoryPayload({
|
||||
llmClient: params.llmClient,
|
||||
session,
|
||||
actionText,
|
||||
resultText: resolution.resultText,
|
||||
});
|
||||
if (generatedPayload) {
|
||||
storyText = generatedPayload.storyText;
|
||||
historyResultText = generatedPayload.historyResultText;
|
||||
options = generatedPayload.presentationOptions;
|
||||
savedCurrentStory = generatedPayload.savedCurrentStory;
|
||||
}
|
||||
} catch {
|
||||
savedCurrentStory = buildLegacyCurrentStory(storyText, options);
|
||||
}
|
||||
}
|
||||
|
||||
appendStoryHistory(session, actionText, historyResultText);
|
||||
syncRawGameState(session);
|
||||
|
||||
const persistedSnapshot = await params.runtimeRepository.putSnapshot(
|
||||
@@ -313,7 +868,7 @@ export async function resolveRuntimeStoryAction(params: {
|
||||
savedAt: new Date().toISOString(),
|
||||
bottomTab: session.snapshotBottomTab,
|
||||
gameState: session.rawGameState,
|
||||
currentStory: buildLegacyCurrentStory(storyText, options),
|
||||
currentStory: savedCurrentStory,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -333,7 +888,7 @@ export async function resolveRuntimeStoryAction(params: {
|
||||
{
|
||||
type: 'story_history_append',
|
||||
actionText,
|
||||
resultText: resolution.resultText,
|
||||
resultText: historyResultText,
|
||||
},
|
||||
...resolution.patches,
|
||||
],
|
||||
|
||||
@@ -340,6 +340,199 @@ function buildBuiltinWorldTitle(worldType: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeGeneratedAssetPath(value: string) {
|
||||
return /^\/generated-/u.test(value);
|
||||
}
|
||||
|
||||
function mergeSnapshotRoleAssets(
|
||||
role: Record<string, unknown>,
|
||||
assets: {
|
||||
imageSrc?: string | null;
|
||||
generatedVisualAssetId?: string | null;
|
||||
generatedAnimationSetId?: string | null;
|
||||
animationMap?: Record<string, unknown> | null;
|
||||
},
|
||||
) {
|
||||
let changed = false;
|
||||
const nextRole: Record<string, unknown> = { ...role };
|
||||
const nextImageSrc = readString(assets.imageSrc);
|
||||
const nextGeneratedVisualAssetId = readString(assets.generatedVisualAssetId);
|
||||
const nextGeneratedAnimationSetId = readString(
|
||||
assets.generatedAnimationSetId,
|
||||
);
|
||||
const nextAnimationMap = asRecord(assets.animationMap);
|
||||
|
||||
if (nextImageSrc && readString(role.imageSrc) !== nextImageSrc) {
|
||||
nextRole.imageSrc = nextImageSrc;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (
|
||||
nextGeneratedVisualAssetId &&
|
||||
readString(role.generatedVisualAssetId) !== nextGeneratedVisualAssetId
|
||||
) {
|
||||
nextRole.generatedVisualAssetId = nextGeneratedVisualAssetId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (
|
||||
nextGeneratedAnimationSetId &&
|
||||
readString(role.generatedAnimationSetId) !== nextGeneratedAnimationSetId
|
||||
) {
|
||||
nextRole.generatedAnimationSetId = nextGeneratedAnimationSetId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (nextAnimationMap && Object.keys(nextAnimationMap).length > 0) {
|
||||
nextRole.animationMap = {
|
||||
...(asRecord(role.animationMap) ?? {}),
|
||||
...nextAnimationMap,
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? nextRole : role;
|
||||
}
|
||||
|
||||
function syncSnapshotRoleAssetsIntoProfile(
|
||||
profile: Record<string, unknown>,
|
||||
roleId: string,
|
||||
assets: Parameters<typeof mergeSnapshotRoleAssets>[1],
|
||||
) {
|
||||
if (!roleId) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const syncRoleArray = (value: unknown) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.map((entry) => {
|
||||
if (!asRecord(entry) || readString(entry.id) !== roleId) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
const nextEntry = mergeSnapshotRoleAssets(entry, assets);
|
||||
if (nextEntry !== entry) {
|
||||
changed = true;
|
||||
}
|
||||
return nextEntry;
|
||||
});
|
||||
};
|
||||
|
||||
const nextPlayableNpcs = syncRoleArray(profile.playableNpcs);
|
||||
const nextStoryNpcs = syncRoleArray(profile.storyNpcs);
|
||||
|
||||
return changed
|
||||
? {
|
||||
...profile,
|
||||
playableNpcs: nextPlayableNpcs,
|
||||
storyNpcs: nextStoryNpcs,
|
||||
}
|
||||
: profile;
|
||||
}
|
||||
|
||||
function syncSnapshotSceneImageIntoProfile(
|
||||
profile: Record<string, unknown>,
|
||||
sceneId: string,
|
||||
imageSrc: string,
|
||||
) {
|
||||
if (!sceneId || !imageSrc) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
if (sceneId === 'custom-scene-camp') {
|
||||
const currentCamp = asRecord(profile.camp) ?? {};
|
||||
if (readString(currentCamp.imageSrc) === imageSrc) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
camp: {
|
||||
...currentCamp,
|
||||
imageSrc,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const landmarkMatch = /^custom-scene-landmark-(\d+)$/u.exec(sceneId);
|
||||
if (!landmarkMatch || !Array.isArray(profile.landmarks)) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
const landmarkIndex = Number.parseInt(landmarkMatch[1] ?? '', 10) - 1;
|
||||
if (
|
||||
!Number.isInteger(landmarkIndex) ||
|
||||
landmarkIndex < 0 ||
|
||||
landmarkIndex >= profile.landmarks.length
|
||||
) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
const currentLandmark = asRecord(profile.landmarks[landmarkIndex]);
|
||||
if (!currentLandmark || readString(currentLandmark.imageSrc) === imageSrc) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
const nextLandmarks = [...profile.landmarks];
|
||||
nextLandmarks[landmarkIndex] = {
|
||||
...currentLandmark,
|
||||
imageSrc,
|
||||
};
|
||||
|
||||
return {
|
||||
...profile,
|
||||
landmarks: nextLandmarks,
|
||||
};
|
||||
}
|
||||
|
||||
function syncSnapshotCustomWorldProfile(gameState: unknown) {
|
||||
const currentGameState = asRecord(gameState);
|
||||
const currentProfile = asRecord(currentGameState?.customWorldProfile);
|
||||
if (!currentGameState || !currentProfile) {
|
||||
return gameState;
|
||||
}
|
||||
|
||||
let nextProfile = currentProfile;
|
||||
const playerCharacter = asRecord(currentGameState.playerCharacter);
|
||||
const playerCharacterId = readString(playerCharacter?.id);
|
||||
const playerPortrait = readString(playerCharacter?.portrait);
|
||||
const playerAnimationMap = asRecord(playerCharacter?.animationMap);
|
||||
const playerHasGeneratedAssets =
|
||||
Boolean(readString(playerCharacter?.generatedVisualAssetId)) ||
|
||||
Boolean(readString(playerCharacter?.generatedAnimationSetId)) ||
|
||||
Boolean(playerAnimationMap && Object.keys(playerAnimationMap).length > 0) ||
|
||||
looksLikeGeneratedAssetPath(playerPortrait);
|
||||
|
||||
nextProfile = syncSnapshotRoleAssetsIntoProfile(nextProfile, playerCharacterId, {
|
||||
imageSrc: playerHasGeneratedAssets ? playerPortrait : null,
|
||||
generatedVisualAssetId:
|
||||
readString(playerCharacter?.generatedVisualAssetId) || null,
|
||||
generatedAnimationSetId:
|
||||
readString(playerCharacter?.generatedAnimationSetId) || null,
|
||||
animationMap: playerAnimationMap,
|
||||
});
|
||||
|
||||
const currentScenePreset = asRecord(currentGameState.currentScenePreset);
|
||||
nextProfile = syncSnapshotSceneImageIntoProfile(
|
||||
nextProfile,
|
||||
readString(currentScenePreset?.id),
|
||||
readString(currentScenePreset?.imageSrc),
|
||||
);
|
||||
|
||||
if (nextProfile === currentProfile) {
|
||||
return currentGameState;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentGameState,
|
||||
customWorldProfile: nextProfile,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProfileWorldSnapshotMeta(
|
||||
snapshot: SavedSnapshot,
|
||||
): ProfileWorldSnapshotMeta | null {
|
||||
@@ -595,6 +788,67 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
});
|
||||
}
|
||||
|
||||
private async syncCustomWorldProfileFromSnapshot(
|
||||
userId: string,
|
||||
snapshot: SavedSnapshot,
|
||||
) {
|
||||
const gameState = asRecord(snapshot.gameState);
|
||||
const customWorldProfile = asRecord(gameState?.customWorldProfile);
|
||||
const profileId = readString(customWorldProfile?.id);
|
||||
|
||||
if (!customWorldProfile || !profileId) {
|
||||
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,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async getSnapshot(userId: string) {
|
||||
const result = await this.db.query<SnapshotRow>(
|
||||
`SELECT version,
|
||||
@@ -625,7 +879,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
const snapshot = {
|
||||
version: SAVE_SNAPSHOT_VERSION,
|
||||
savedAt: payload.savedAt,
|
||||
gameState: payload.gameState,
|
||||
gameState: syncSnapshotCustomWorldProfile(payload.gameState),
|
||||
bottomTab: payload.bottomTab,
|
||||
currentStory: payload.currentStory,
|
||||
} satisfies SavedSnapshot;
|
||||
@@ -668,6 +922,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
} satisfies SavedSnapshot;
|
||||
|
||||
await this.syncProfileDashboardFromSnapshot(userId, persistedSnapshot);
|
||||
await this.syncCustomWorldProfileFromSnapshot(userId, persistedSnapshot);
|
||||
|
||||
return persistedSnapshot;
|
||||
}
|
||||
|
||||
@@ -451,24 +451,9 @@ function buildFallbackRoleDraft(
|
||||
: `长期活跃于当前世界暗面,能补足场景视角的关键角色。`,
|
||||
60,
|
||||
),
|
||||
visualDescription: clampText(
|
||||
kind === 'playable'
|
||||
? `他保留着适合长期同行的鲜明外形识别点,服装、装备和体态都能直接看出其职责、出身和会如何与玩家并肩行动。`
|
||||
: `他身上带着与当前局势强绑定的外观痕迹,衣着、器具和整体气质会暴露其长期活动环境与所站的位置。`,
|
||||
96,
|
||||
),
|
||||
actionDescription: clampText(
|
||||
kind === 'playable'
|
||||
? '动作表现偏向协作推进与稳定压制,起手克制,发力明确,收招干净。'
|
||||
: '动作表现偏向试探、牵制与借势,节奏谨慎,但关键时刻会突然加重攻击或位移。',
|
||||
72,
|
||||
),
|
||||
sceneVisualDescription: clampText(
|
||||
profile.landmarks[0]?.description
|
||||
? `他的主要活动空间与${profile.landmarks[0].name}相连,场景里能看到${profile.landmarks[0].description}`
|
||||
: `他的主要活动空间与${profile.name}当前冲突线直接相关,环境里会留下势力痕迹、旧装置和仍在运转的局势线索。`,
|
||||
96,
|
||||
),
|
||||
visualDescription: '',
|
||||
actionDescription: '',
|
||||
sceneVisualDescription: '',
|
||||
backstory: clampText(
|
||||
`他与${profile.name}当前正在扩张的冲突链紧密相连,知道一些还未公开的内情。`,
|
||||
80,
|
||||
@@ -567,10 +552,7 @@ function buildFallbackLandmarkDraft(profile: ParsedProfile) {
|
||||
`承接${profile.name}当前主冲突的一处关键新场景,适合继续向外扩张世界关系网。`,
|
||||
72,
|
||||
),
|
||||
visualDescription: clampText(
|
||||
`这里延续${profile.name}当前主冲突的视觉气质,能看到明确的空间层次、可站立地面、核心建筑或地貌,以及仍在运转的局势痕迹。`,
|
||||
88,
|
||||
),
|
||||
visualDescription: '',
|
||||
dangerLevel: 'medium',
|
||||
sceneNpcNames,
|
||||
connections: targetLandmarkNames.map((targetLandmarkName, index) => ({
|
||||
|
||||
@@ -112,7 +112,7 @@ test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves t
|
||||
|
||||
if (
|
||||
req.method === 'POST' &&
|
||||
url.pathname === '/api/v1/services/aigc/image-generation/generation'
|
||||
url.pathname === '/api/v1/services/aigc/text2image/image-synthesis'
|
||||
) {
|
||||
sendJson(res, {
|
||||
output: {
|
||||
@@ -168,27 +168,23 @@ test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves t
|
||||
assert.equal(result.actualPrompt, '整理后的场景提示词');
|
||||
|
||||
const createRequest = capturedRequests.find(
|
||||
(entry) => entry.pathname === '/api/v1/services/aigc/image-generation/generation',
|
||||
(entry) => entry.pathname === '/api/v1/services/aigc/text2image/image-synthesis',
|
||||
);
|
||||
assert.ok(createRequest?.bodyText);
|
||||
|
||||
const createPayload = JSON.parse(createRequest.bodyText) as {
|
||||
model: string;
|
||||
input: {
|
||||
messages: Array<{
|
||||
content: Array<{ text?: string; image?: string }>;
|
||||
}>;
|
||||
};
|
||||
parameters: {
|
||||
prompt: string;
|
||||
negative_prompt?: string;
|
||||
};
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const content = createPayload.input.messages[0]?.content ?? [];
|
||||
assert.equal(createPayload.model, 'wan2.2-t2i-flash');
|
||||
assert.equal(content[0]?.text, '海雾港口像素风场景');
|
||||
assert.equal(content.length, 1);
|
||||
assert.equal(createPayload.parameters.negative_prompt, '模糊');
|
||||
assert.equal(createPayload.input.prompt, '海雾港口像素风场景');
|
||||
assert.equal(createPayload.input.negative_prompt, '模糊');
|
||||
assert.equal(createPayload.parameters.size, '1280*720');
|
||||
|
||||
const savedImagePath = path.join(tempRoot, 'public', result.imageSrc.slice(1));
|
||||
assert.equal(fs.existsSync(savedImagePath), true);
|
||||
|
||||
@@ -130,34 +130,32 @@ async function createSceneImageTask(params: {
|
||||
payload: z.infer<typeof sceneImageSchema>;
|
||||
}) {
|
||||
const { baseUrl, apiKey, payload } = params;
|
||||
const response = await fetch(`${baseUrl}/services/aigc/image-generation/generation`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-DashScope-Async': 'enable',
|
||||
const response = await fetch(
|
||||
`${baseUrl}/services/aigc/text2image/image-synthesis`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-DashScope-Async': 'enable',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: payload.model,
|
||||
input: {
|
||||
prompt: payload.prompt,
|
||||
...(payload.negativePrompt
|
||||
? { negative_prompt: payload.negativePrompt }
|
||||
: {}),
|
||||
},
|
||||
parameters: {
|
||||
n: 1,
|
||||
size: payload.size,
|
||||
prompt_extend: true,
|
||||
watermark: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: payload.model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ text: payload.prompt }],
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
n: 1,
|
||||
size: payload.size,
|
||||
prompt_extend: true,
|
||||
watermark: false,
|
||||
...(payload.negativePrompt
|
||||
? { negative_prompt: payload.negativePrompt }
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
);
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
Reference in New Issue
Block a user