Persist custom world asset configs in runtime snapshots

This commit is contained in:
2026-04-18 17:00:46 +08:00
parent 7ce61e9879
commit ac801fe05f
29 changed files with 3397 additions and 400 deletions

View File

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

View File

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

View File

@@ -42,6 +42,7 @@ export function createStoryActionRoutes(context: AppContext) {
response,
await resolveRuntimeStoryAction({
runtimeRepository: context.runtimeRepository,
llmClient: context.llmClient,
userId: request.userId!,
request: payload,
}),

View File

@@ -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,
],