1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-20 09:54:17 +08:00
parent 67c584b4df
commit 50759f3c1e
159 changed files with 16938 additions and 16925 deletions

View File

@@ -5,28 +5,34 @@ import type {
CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
type NpcChatPendingQuestOffer,
NpcChatTurnRequest,
NpcRecruitDialogueRequest,
} from '../../../../packages/shared/src/contracts/story.js';
import { parseLineListContent } from '../../../../packages/shared/src/llm/parsers.js';
import { prepareEventStreamResponse } from '../../http.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js';
import { generateQuestForNpcEncounter } from '../../services/questService.js';
import {
applyQuestSignal,
getQuestForIssuer,
} from '../quest/questProgressionService.js';
import {
buildCharacterPanelChatPrompt,
buildCharacterPanelChatSuggestionPrompt,
buildCharacterPanelChatSummaryPrompt,
buildNpcChatTurnReplyPrompt,
buildNpcChatTurnSuggestionPrompt,
buildNpcRecruitDialoguePrompt,
buildStrictNpcChatDialoguePrompt,
CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT,
CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT,
CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
buildNpcRecruitDialoguePrompt,
buildStrictNpcChatDialoguePrompt,
buildNpcChatTurnReplyPrompt,
buildNpcChatTurnSuggestionPrompt,
NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT,
NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT,
NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
} from './chatPromptBuilders.js';
import { prepareEventStreamResponse } from '../../http.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js';
function writeSseEvent(
response: Response,
@@ -47,6 +53,14 @@ function readNumber(value: unknown, fallback = 0) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function readArray(value: unknown) {
return Array.isArray(value) ? value : [];
}
function countKeywordMatches(text: string, keywords: string[]) {
return keywords.reduce(
(count, keyword) => (text.includes(keyword) ? count + 1 : count),
@@ -135,6 +149,98 @@ function buildFallbackNpcChatSuggestions(playerMessage: string) {
];
}
function buildQuestOfferDialogueText(
npcName: string,
quest: Record<string, unknown>,
) {
const summaryText =
readString(quest.summary) || readString(quest.description);
return `${npcName}沉吟了片刻,像是终于把真正想托付的事说了出来。${
summaryText
? `如果你愿意,我想把这件事正式交给你:${summaryText}`
: '如果你愿意,我想把眼前这件事正式交给你。'
}`;
}
async function maybeBuildPendingNpcQuestOffer(
llmClient: UpstreamLlmClient,
payload: NpcChatTurnRequest,
affinityDelta: number,
): Promise<NpcChatPendingQuestOffer | null> {
const questOfferContext = readRecord(payload.questOfferContext);
const state = readRecord(questOfferContext?.state);
const encounter = readRecord(questOfferContext?.encounter);
if (!state || !encounter) {
return null;
}
const npcId = readString(encounter.id) || readString(encounter.npcName);
const npcName = readString(encounter.npcName);
const characterId = readString(encounter.characterId);
if (!npcId || !npcName || !characterId) {
return null;
}
const turnCount = Math.max(
0,
Math.round(readNumber(questOfferContext?.turnCount, 0)),
);
const npcStates = readRecord(state.npcStates) ?? {};
const currentNpcState = readRecord(npcStates[npcId]) ?? {};
const currentQuests = readArray(state.quests) as Parameters<
typeof getQuestForIssuer
>[0];
const questsAfterTalk = applyQuestSignal(currentQuests, {
kind: 'npc_talk_completed',
npcId,
}).nextQuests;
const nextAffinity = readNumber(currentNpcState.affinity, 0) + affinityDelta;
const warmupTurns = nextAffinity >= 30 ? 1 : 2;
if (nextAffinity <= 0 || turnCount < warmupTurns) {
return null;
}
if (getQuestForIssuer(questsAfterTalk, npcId)) {
return null;
}
const nextState = {
...state,
quests: questsAfterTalk,
npcStates: {
...npcStates,
[npcId]: {
...currentNpcState,
affinity: nextAffinity,
chattedCount: Math.max(
0,
Math.round(readNumber(currentNpcState.chattedCount, 0)),
) + 1,
firstMeaningfulContactResolved: true,
},
},
};
const quest = await generateQuestForNpcEncounter(llmClient, {
state: nextState as never,
encounter: encounter as never,
});
if (!quest || typeof quest !== 'object') {
return null;
}
return {
quest,
introText: buildQuestOfferDialogueText(
npcName,
quest as Record<string, unknown>,
),
};
}
export async function generateCharacterChatSuggestionsFromOrchestrator(
llmClient: UpstreamLlmClient,
payload: CharacterChatSuggestionsRequest,
@@ -229,6 +335,11 @@ export async function streamNpcChatTurnFromOrchestrator(
npcReply: npcReply || streamedReply,
chattedCount,
});
const pendingQuestOffer = await maybeBuildPendingNpcQuestOffer(
llmClient,
params.payload,
affinityDelta,
);
writeSseEvent(params.response, 'complete', {
npcReply: npcReply || streamedReply,
@@ -238,6 +349,7 @@ export async function streamNpcChatTurnFromOrchestrator(
suggestions.length === 3
? suggestions
: buildFallbackNpcChatSuggestions(params.payload.playerMessage),
pendingQuestOffer,
});
params.response.write('data: [DONE]\n\n');
params.response.end();

View File

@@ -5,23 +5,23 @@ import type {
} from '../../../../packages/shared/src/contracts/runtime.js';
import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js';
import {
buildCompiledCustomWorldProfile,
MIN_CUSTOM_WORLD_LANDMARK_COUNT,
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
validateGeneratedCustomWorldProfile,
} from '../../../../src/services/customWorld.js';
import { buildExpandedCustomWorldProfile } from '../../../../src/services/customWorldBuilder.js';
} from '../custom-world/runtimeProfile.js';
import {
buildCustomWorldAnchorPackFromIntent,
buildCustomWorldCreatorIntentGenerationText,
deriveCustomWorldLockStateFromIntent,
hasMeaningfulCustomWorldCreatorIntent,
normalizeCustomWorldCreatorIntent,
} from '../../../../src/services/customWorldCreatorIntent.js';
} from '../custom-world/creatorIntentRuntime.js';
import type {
CustomWorldCreatorIntent,
CustomWorldProfile,
} from '../../../../src/types.js';
} from '../custom-world/runtimeTypes.js';
import {
buildCustomWorldProfilePrompt,
buildCustomWorldProfileRepairPrompt,
@@ -396,7 +396,7 @@ export async function generateCustomWorldProfileFromOrchestrator(
reporter.complete('llm-profile', '模型已返回世界档案,开始系统归一与运行时编译。');
reporter.begin('normalize', '正在把模型 JSON 归一为可运行的自定义世界结构。');
const expandedProfile = buildExpandedCustomWorldProfile(
const expandedProfile = buildCompiledCustomWorldProfile(
{
...(rawProfile as GeneratedProfile),
settingText,

View File

@@ -3,10 +3,12 @@ import test from 'node:test';
import type {
CharacterChatSuggestionsRequest,
NpcChatTurnRequest,
} from '../../../../packages/shared/src/contracts/story.js';
import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js';
import {
generateCharacterChatSuggestionsFromOrchestrator,
streamNpcChatTurnFromOrchestrator,
} from './chatOrchestrator.js';
import { CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT } from './chatPromptBuilders.js';
import {
@@ -195,6 +197,179 @@ test('chat orchestrator builds character suggestion prompts on the server side',
assert.match(capturedPrompts[0]?.userPrompt ?? '', new RegExp(payload.targetCharacter.name, 'u'));
});
test('chat orchestrator returns pending npc quest offers from the server side', async () => {
const encounter = {
kind: 'npc',
id: 'npc_scout_01',
npcName: '巡路人',
npcDescription: '熟悉桥口风向的探子',
context: '巡路人',
characterId: 'scout-quest',
} as const;
const requestPayload = {
worldType: TEST_WORLD,
character: createTestCharacter(),
player: createTestCharacter(),
encounter,
monsters: [],
history: [],
context: createStoryContext(),
conversationHistory: [
{ speaker: 'player', text: '你像是还有别的话想说。' },
{ speaker: 'npc', text: '这地方最近确实不太平。' },
],
dialogue: [
{ speaker: 'player', text: '你像是还有别的话想说。' },
{ speaker: 'npc', text: '这地方最近确实不太平。' },
],
playerMessage: '如果你愿意,我可以继续追下去。',
npcState: {
affinity: 28,
chattedCount: 1,
},
questOfferContext: {
turnCount: 2,
encounter,
state: {
worldType: TEST_WORLD,
currentScenePreset: {
id: 'quest-bridge',
name: '断桥口',
description: '桥口被风和旧账压得很紧。',
npcs: [
{
id: 'npc_bandit_01',
name: '断桥匪首',
hostile: true,
monsterPresetId: 'npc_bandit_01',
},
],
treasureHints: [],
},
currentEncounter: encounter,
storyHistory: [],
customWorldProfile: null,
storyEngineMemory: null,
playerCharacter: createTestCharacter(),
playerHp: 32,
playerMaxHp: 40,
playerMana: 12,
playerMaxMana: 16,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
companions: [],
roster: [],
quests: [],
npcStates: {
npc_scout_01: {
affinity: 28,
chattedCount: 1,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
},
},
} satisfies NpcChatTurnRequest;
const responseChunks: string[] = [];
let requestCount = 0;
const llmClient = {
streamMessageContent: async ({
onUpdate,
}: {
onUpdate?: (text: string) => void;
}) => {
const reply = '如果你真愿意追查,我这里确实有件事想托给你。';
onUpdate?.(reply);
return reply;
},
requestMessageContent: async () => {
requestCount += 1;
if (requestCount === 1) {
return '这件事最早是从什么时候开始的\n桥口最近到底少了什么人\n你想让我先盯哪条线';
}
return JSON.stringify({
intent: {
title: '断桥巡线',
description: '巡路人希望你去断桥口查清最近被人故意压下的风声。',
summary: '去断桥口查清最近被人故意压下的风声。',
narrativeType: 'investigation',
dramaticNeed: '有人在桥口刻意遮掩痕迹。',
issuerGoal: '确认是谁在桥口截断消息。',
playerHook: '你可以顺着桥口的异常把事情继续追下去。',
worldReason: '桥口异动会影响接下来整段路的安全。',
recommendedObjectiveKinds: ['defeat_hostile_npc', 'talk_to_npc'],
urgency: 'medium',
intimacy: 'cooperative',
rewardTheme: 'currency',
followupHooks: ['巡路人还藏着半句没说完的话。'],
},
});
},
} as const;
const request = {
method: 'POST',
originalUrl: '/api/runtime/chat/npc/turn/stream',
requestId: 'test-request',
requestStartedAt: Date.now(),
header: () => '',
on: () => request,
} as never;
const response = {
locals: {},
statusCode: 200,
setHeader: () => undefined,
status(code: number) {
this.statusCode = code;
return this;
},
write(chunk: string) {
responseChunks.push(chunk);
return true;
},
end(chunk?: string) {
if (chunk) {
responseChunks.push(chunk);
}
return this;
},
} as never;
await streamNpcChatTurnFromOrchestrator(llmClient as never, {
request,
response,
payload: requestPayload,
});
const eventText = responseChunks.join('');
const completeBlock = eventText
.split('\n\n')
.find((block) => block.includes('event: complete'));
assert.ok(completeBlock);
const completeLine = completeBlock
?.split('\n')
.find((line) => line.startsWith('data:'));
assert.ok(completeLine);
const payload = JSON.parse(completeLine!.slice(5).trim()) as {
pendingQuestOffer?: {
quest?: {
issuerNpcId?: string;
};
introText?: string;
} | null;
};
assert.equal(payload.pendingQuestOffer?.quest?.issuerNpcId, 'npc_scout_01');
assert.match(payload.pendingQuestOffer?.introText ?? '', //u);
});
test('custom world orchestrator requests LLM content before compiling the profile', async () => {
const capturedPrompts: Array<{ systemPrompt: string; userPrompt: string }> = [];
const storyNpcNames = Array.from(

View File

@@ -17,14 +17,22 @@ import {
import { PNG } from 'pngjs';
import { removeBackgroundFromRgba } from '../../../../packages/shared/src/assets/chromaKey.js';
import {
buildMasterPrompt,
buildVideoActionPrompt,
getActionTemplateById,
} from '../../../../packages/shared/src/assets/qwenSprite.js';
import { parseApiErrorMessage } from '../../../../packages/shared/src/http.js';
import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js';
import type { AppConfig } from '../../config.js';
import {
buildArkCharacterAnimationPrompt,
buildCharacterPromptBundleUserPrompt,
buildFallbackCharacterPromptBundle,
buildFallbackModerationSafeAnimationPrompt,
buildImageSequencePrompt,
buildNpcAnimationPrompt,
buildNpcVisualNegativePrompt,
buildNpcVisualPrompt,
CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT,
type CharacterPromptBundle,
sanitizeCharacterPromptBundle,
} from '../../prompts/characterAssetPrompts.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js';
const CHARACTER_PROMPT_BUNDLE_GENERATE_PATH =
@@ -56,24 +64,6 @@ const DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS = 15000;
const DASHSCOPE_IMAGE_TASK_TIMEOUT_MS = 180000;
const DASHSCOPE_VIDEO_TASK_TIMEOUT_MS = 420000;
const ARK_VIDEO_TASK_POLL_INTERVAL_MS = 5000;
const CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT = `你是 RPG 角色资产提示词编译器。
你会收到一个角色设定摘要,请为当前项目生成 3 段可直接交给资产生成模型的中文提示词。
你必须只输出一个 JSON 对象,不要输出 Markdown、代码块、注释或解释。
输出格式必须严格为:
{
"visualPromptText": "角色主图提示词",
"animationPromptText": "角色动作提示词",
"scenePromptText": "角色关联场景提示词"
}
硬性约束:
- 所有字段都必须是自然中文。
- visualPromptText 用于角色主图候选必须是角色标准设定图而不是场景海报突出单人全身、右向斜侧身站姿、脚底完整可见、服装武器轮廓稳定、纯绿色绿幕背景、1:1 画幅。
- visualPromptText 里的主题词只能落在角色自身的服装、发型、材质、纹样、饰品、武器和发光细节上,不要自动补出建筑、风景、漂浮物、烟雾或其他角色以外的场景元素。
- visualPromptText 要明确“身体整体朝右,但保留少量正面信息”,避免生成完全 90 度纯右视图。
- animationPromptText 用于角色动作试片,必须突出发力方式、动作气质、连贯性、同一角色一致性,不要写镜头切换。
- scenePromptText 用于该角色关联的场景背景,必须突出角色首次登场或主活动区域的环境气质与空间结构,适配横版 RPG 场景。
- 三段提示词都要可直接使用,不要编号,不要加字段名解释,不要输出负面提示词。`;
const BUILT_IN_MOTION_TEMPLATES = [
{
@@ -142,14 +132,6 @@ function applyChromaKeyToMediaPayload(payload: DecodedMediaPayload) {
} satisfies DecodedMediaPayload;
}
type CharacterPromptBundle = {
visualPromptText: string;
animationPromptText: string;
scenePromptText: string;
source: 'llm' | 'fallback';
model: string | null;
};
type CharacterAssetWorkflowCacheRecord = {
characterId: string;
visualPromptText: string;
@@ -306,128 +288,6 @@ function clampPromptSeedText(value: unknown, maxLength: number) {
return value.replace(/\s+/gu, ' ').trim().slice(0, maxLength);
}
function buildFallbackCharacterPromptBundle(params: {
characterName: string;
roleKind: string;
roleTitle: string;
roleLabel: string;
description: string;
backstory: string;
personality: string;
motivation: string;
combatStyle: string;
tags: string[];
}) {
const roleAnchor =
[params.roleTitle, params.roleLabel].filter(Boolean).join(' / ') ||
(params.roleKind === 'playable' ? '可扮演角色' : '场景角色');
const characterAnchor = params.characterName || '该角色';
const descriptionAnchor =
params.description || params.backstory || params.personality || '气质鲜明';
const combatAnchor =
params.combatStyle || params.motivation || '动作发力清晰';
const tagAnchor =
params.tags.length > 0 ? `保留 ${params.tags.join('、')} 的识别点。` : '';
return {
visualPromptText: [
`${characterAnchor}${roleAnchor}`,
'单人全身2D 横版 RPG 角色标准设定图1:1 正方形画幅,头身比控制在 2 到 2.5 头身,偏大头身,靠头部、发型、服装、配饰表现角色记忆点,躯干与四肢短而紧凑,五官简化,深色粗轮廓配合清晰大色块,右向斜侧身站立,身体整体朝右但保留少量正面信息,服装、发型、轮廓稳定清楚。',
`外观气质围绕:${descriptionAnchor}`,
combatAnchor ? `战斗识别点:${combatAnchor}` : '',
tagAnchor,
'背景固定为纯绿色绿幕,不带建筑、风景、漂浮物和其他场景元素,方便自动抠像,不做正面立绘,不做完全 90 度纯右视图,不做夸张透视。',
]
.filter(Boolean)
.join(' '),
animationPromptText: [
`${characterAnchor}的核心动作试片。`,
'保持同一角色的服装、发型、体型一致,镜头稳定,侧身朝右,动作连贯。',
combatAnchor ? `动作气质参考:${combatAnchor}` : '',
params.personality ? `角色气质补充:${params.personality}` : '',
'发力起手明确,过程干净,收招利落,避免漂移和变形。',
]
.filter(Boolean)
.join(' '),
scenePromptText: [
`${characterAnchor}关联主场景,适合作为首次登场区域或常驻活动空间。`,
'16:9 横版 RPG 场景背景,上下分区清楚,上半部分表现中远景氛围,下半部分是可站立地面。',
`场景叙事气质围绕:${descriptionAnchor}`,
params.backstory ? `背景线索可参考:${params.backstory}` : '',
params.motivation
? `环境中可埋入与当前目标相关的暗示:${params.motivation}`
: '',
'整体风格克制统一,适合剧情探索与战斗底图。',
]
.filter(Boolean)
.join(' '),
source: 'fallback' as const,
model: null,
};
}
function sanitizePromptBundleValue(
value: unknown,
fallback: string,
maxLength: number,
) {
const normalized = clampPromptSeedText(value, maxLength);
return normalized || fallback;
}
function sanitizeCharacterPromptBundle(
value: unknown,
fallback: CharacterPromptBundle,
model: string,
) {
const record = isRecordValue(value) ? value : {};
return {
visualPromptText: sanitizePromptBundleValue(
record.visualPromptText,
fallback.visualPromptText,
280,
),
animationPromptText: sanitizePromptBundleValue(
record.animationPromptText,
fallback.animationPromptText,
280,
),
scenePromptText: sanitizePromptBundleValue(
record.scenePromptText,
fallback.scenePromptText,
320,
),
source: 'llm' as const,
model: model.trim() || null,
};
}
function sanitizeAnimationPromptText(value: string, maxLength: number) {
return value
.replace(/\s+/gu, ' ')
.replace(/||||||||/gu, '')
.replace(/||/gu, '')
.replace(/|/gu, '')
.replace(/|/gu, '')
.trim()
.slice(0, maxLength);
}
function buildCompactAnimationCharacterBrief(value: string) {
const normalized = sanitizeAnimationPromptText(value, 160);
if (!normalized) {
return '';
}
return normalized
.split(/[/|\n,;]+/u)
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 4)
.join('');
}
function isInappropriateContentMessage(value: string) {
return /finappropriate-content|inappropriate content||/iu.test(
value,
@@ -489,42 +349,6 @@ async function proxyJsonRequestWithPromptFallback(params: {
};
}
function buildCharacterPromptBundleUserPrompt(params: {
roleKind: string;
characterBriefText: string;
characterName: string;
roleTitle: string;
roleLabel: string;
description: string;
backstory: string;
personality: string;
motivation: string;
combatStyle: string;
tags: string[];
}) {
return [
'请根据下面的角色卡摘要,编译一组默认资产提示词。',
'提示词用于当前项目的角色主图、动作试片和角色关联场景背景。',
'请保留该角色的身份识别点、气质、战斗方式与世界感,不要空泛套模板。',
'',
`角色类型:${params.roleKind === 'playable' ? '可扮演角色' : '场景角色'}`,
params.characterName ? `角色名称:${params.characterName}` : '',
params.roleTitle ? `角色头衔:${params.roleTitle}` : '',
params.roleLabel ? `世界身份:${params.roleLabel}` : '',
params.description ? `角色描述:${params.description}` : '',
params.backstory ? `角色背景:${params.backstory}` : '',
params.personality ? `角色性格:${params.personality}` : '',
params.motivation ? `角色动机:${params.motivation}` : '',
params.combatStyle ? `战斗风格:${params.combatStyle}` : '',
params.tags.length > 0 ? `角色标签:${params.tags.join('、')}` : '',
'',
'角色卡全文:',
params.characterBriefText,
]
.filter(Boolean)
.join('\n');
}
function createTimestampId(prefix: string) {
return `${prefix}-${Date.now()}`;
}
@@ -1211,187 +1035,6 @@ function extractImageUrls(payload: Record<string, unknown>) {
return [...new Set(urls)];
}
function buildNpcVisualPrompt(promptText: string, characterBriefText = '') {
const mergedBrief = [characterBriefText.trim(), promptText.trim()]
.filter(Boolean)
.join('\n');
return buildMasterPrompt(
mergedBrief || '自定义世界角色,服装完整,姿态自然。',
);
}
function buildNpcVisualNegativePrompt() {
return [
'正面视角',
'左朝向',
'完全 90 度纯右视图',
'镜头透视',
'半身像',
'脚被裁切',
'头顶被裁切',
'多角色',
'复杂背景',
'建筑场景',
'漂浮物',
'烟雾环境',
'武器消失',
'武器换手',
'额外手臂',
'额外腿',
'服装变化',
'脸部变化',
'模糊',
'运动模糊',
'文字',
'水印',
'UI 元素',
'软萌 Q版大头贴',
'儿童绘本风',
'厚涂插画感',
'低对比柔边',
].join('');
}
function buildImageSequencePrompt(
animation: string,
promptText: string,
frameCount: number,
useChromaKey: boolean,
) {
return [
`同一角色连续 ${frameCount} 帧动作序列,动作主题是 ${animation}`,
'固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。',
'帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。',
useChromaKey
? '纯绿色背景,无地面装饰,方便后期抠像。'
: '背景尽量纯净,避免复杂场景。',
promptText.trim(),
]
.filter(Boolean)
.join(' ');
}
function buildNpcAnimationPrompt(options: {
animation: string;
promptText: string;
useChromaKey: boolean;
loop: boolean;
characterBriefText?: string;
actionTemplateId?: string;
}) {
const characterBrief = buildCompactAnimationCharacterBrief(
options.characterBriefText ?? '',
);
const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140);
const loopRule = options.loop
? '这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。'
: options.animation === 'die'
? '这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。'
: '这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。';
if (options.actionTemplateId) {
return [
buildVideoActionPrompt({
actionTemplate: getActionTemplateById(
options.actionTemplateId as Parameters<
typeof getActionTemplateById
>[0],
),
actionDetailText,
useChromaKey: options.useChromaKey,
characterBrief: characterBrief || `${options.animation} 动作角色`,
}),
loopRule,
]
.filter(Boolean)
.join(' ');
}
return [
`单人 NPC 全身动作视频,动作主题是 ${options.animation}`,
'角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。',
'动作连贯,避免服装、发型、面部、武器随机漂移。',
options.useChromaKey
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。'
: '背景简洁纯净,无复杂场景。',
characterBrief ? `角色设定:${characterBrief}` : '',
actionDetailText,
loopRule,
]
.filter(Boolean)
.join(' ');
}
function buildArkCharacterAnimationPrompt(options: {
animation: string;
promptText: string;
useChromaKey: boolean;
loop: boolean;
characterBriefText?: string;
actionTemplateId?: string;
}) {
const normalizedAnimationName =
options.animation.trim().replace(/\s+/gu, '_') || 'idle';
const characterBrief = buildCompactAnimationCharacterBrief(
options.characterBriefText ?? '',
);
const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140);
const frameRule = options.loop
? '首帧严格使用图片1尾帧严格使用图片2循环动作必须自然闭环不要静止开场。'
: '首帧严格使用图片1尾帧严格使用图片2中段完成完整动作变化收束干净。';
if (options.actionTemplateId) {
return [
buildVideoActionPrompt({
actionTemplate: getActionTemplateById(
options.actionTemplateId as Parameters<typeof getActionTemplateById>[0],
),
actionDetailText,
useChromaKey: options.useChromaKey,
characterBrief: characterBrief || `${normalizedAnimationName} action role`,
}),
`动作英文名:${normalizedAnimationName}`,
frameRule,
]
.filter(Boolean)
.join(' ');
}
return [
`单人 NPC 全身动作视频,动作英文名是 ${normalizedAnimationName}`,
'角色固定为图片1和图片2中的同一人侧身朝右镜头稳定轮廓清晰武器不可丢失。',
'动作连贯,避免服装、发型、面部、武器随机漂移,不要多角色,不要镜头切换。',
options.useChromaKey
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。'
: '背景简洁纯净,无复杂场景。',
characterBrief ? `角色设定:${characterBrief}` : '',
actionDetailText ? `动作细节:${actionDetailText}` : '',
frameRule,
]
.filter(Boolean)
.join(' ');
}
function buildFallbackModerationSafeAnimationPrompt(options: {
animation: string;
loop: boolean;
useChromaKey: boolean;
}) {
return [
`单人全身角色动作视频,动作主题是 ${options.animation}`,
'角色固定为同一人,右向斜侧身,镜头稳定,轮廓清楚。',
options.loop
? '循环动作直接进入稳定循环,不要静止开场,不要定格首帧。'
: '非循环动作首尾回到角色标准站姿,中段完成动作变化。',
options.useChromaKey
? '背景为纯绿色绿幕,无其他人物和场景元素。'
: '背景简洁纯净。',
]
.filter(Boolean)
.join(' ');
}
function getLowestSupportedVideoResolution(model: string, fallback: string) {
switch (model) {
case 'wan2.6-i2v-flash':

View File

@@ -0,0 +1,528 @@
import type {
ActorAnchor,
CreatorCharacterSeed,
CreatorFactionSeed,
CreatorLandmarkSeed,
CustomWorldAnchorPack,
CustomWorldCreatorIntent,
CustomWorldLockState,
LandmarkAnchor,
} from './runtimeTypes.js';
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function toStringArray(value: unknown, maxCount = 8) {
if (!Array.isArray(value)) {
return [];
}
return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice(
0,
maxCount,
);
}
function slugify(value: string) {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'entry';
}
function createSeedId(prefix: string, label: string, index: number) {
return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`;
}
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()}`;
}
function normalizeCreatorFactionSeed(
value: unknown,
index: number,
): CreatorFactionSeed | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const name = toText(item.name);
const publicGoal = toText(item.publicGoal);
const tension = toText(item.tension);
const notes = toText(item.notes);
if (!name && !publicGoal && !tension && !notes) {
return null;
}
return {
id:
toText(item.id) ||
createSeedId('creator-faction', name || publicGoal, index),
name,
publicGoal,
tension,
notes,
locked: Boolean(item.locked),
};
}
function normalizeCreatorCharacterSeed(
value: unknown,
index: number,
): CreatorCharacterSeed | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const name = toText(item.name);
const role = toText(item.role);
const publicMask = toText(item.publicMask);
const hiddenHook = toText(item.hiddenHook);
const relationToPlayer = toText(item.relationToPlayer);
const notes = toText(item.notes);
if (
!name &&
!role &&
!publicMask &&
!hiddenHook &&
!relationToPlayer &&
!notes
) {
return null;
}
return {
id:
toText(item.id) ||
createSeedId('creator-character', name || role || publicMask, index),
name,
role,
publicMask,
hiddenHook,
relationToPlayer,
notes,
locked: Boolean(item.locked),
};
}
function normalizeCreatorLandmarkSeed(
value: unknown,
index: number,
): CreatorLandmarkSeed | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const name = toText(item.name);
const purpose = toText(item.purpose);
const mood = toText(item.mood);
const secret = toText(item.secret);
if (!name && !purpose && !mood && !secret) {
return null;
}
return {
id:
toText(item.id) ||
createSeedId('creator-landmark', name || purpose || mood, index),
name,
purpose,
mood,
secret,
locked: Boolean(item.locked),
};
}
function normalizeAnchorArray<T>(
value: unknown,
normalizer: (value: unknown, index: number) => T | null,
maxCount: number,
) {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item, index) => normalizer(item, index))
.filter((item): item is T => Boolean(item))
.slice(0, maxCount);
}
export function normalizeCustomWorldCreatorIntent(
value: unknown,
fallbackMode: CustomWorldCreatorIntent['sourceMode'] = 'freeform',
): CustomWorldCreatorIntent | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const sourceMode =
item.sourceMode === 'card' || item.sourceMode === 'freeform'
? item.sourceMode
: fallbackMode;
const rawSettingText = toText(item.rawSettingText);
const worldHook = toText(item.worldHook);
const playerPremise = toText(item.playerPremise);
const openingSituation = toText(item.openingSituation);
const themeKeywords = toStringArray(item.themeKeywords, 8);
const toneDirectives = toStringArray(item.toneDirectives, 8);
const coreConflicts = toStringArray(item.coreConflicts, 6);
const iconicElements = toStringArray(item.iconicElements, 8);
const forbiddenDirectives = toStringArray(item.forbiddenDirectives, 8);
const keyFactions = normalizeAnchorArray(
item.keyFactions,
normalizeCreatorFactionSeed,
6,
);
const keyCharacters = normalizeAnchorArray(
item.keyCharacters,
normalizeCreatorCharacterSeed,
8,
);
const keyLandmarks = normalizeAnchorArray(
item.keyLandmarks,
normalizeCreatorLandmarkSeed,
8,
);
if (
!rawSettingText &&
!worldHook &&
themeKeywords.length === 0 &&
toneDirectives.length === 0 &&
!playerPremise &&
!openingSituation &&
coreConflicts.length === 0 &&
keyFactions.length === 0 &&
keyCharacters.length === 0 &&
keyLandmarks.length === 0 &&
iconicElements.length === 0 &&
forbiddenDirectives.length === 0
) {
return null;
}
return {
sourceMode,
rawSettingText,
worldHook,
themeKeywords,
toneDirectives,
playerPremise,
openingSituation,
coreConflicts,
keyFactions,
keyCharacters,
keyLandmarks,
iconicElements,
forbiddenDirectives,
};
}
export function normalizeCustomWorldLockState(
value: unknown,
): CustomWorldLockState {
if (!value || typeof value !== 'object') {
return {
worldLockedFields: [],
lockedCharacterIds: [],
lockedLandmarkIds: [],
lockedConflictIds: [],
lockedFactionIds: [],
};
}
const item = value as Record<string, unknown>;
return {
worldLockedFields: toStringArray(item.worldLockedFields, 12),
lockedCharacterIds: toStringArray(item.lockedCharacterIds, 20),
lockedLandmarkIds: toStringArray(item.lockedLandmarkIds, 20),
lockedConflictIds: toStringArray(item.lockedConflictIds, 20),
lockedFactionIds: toStringArray(item.lockedFactionIds, 20),
};
}
export function deriveCustomWorldLockStateFromIntent(
intent: CustomWorldCreatorIntent | null | undefined,
): CustomWorldLockState {
return {
worldLockedFields: [],
lockedCharacterIds:
intent?.keyCharacters
.filter((entry) => entry.locked)
.map((entry) => entry.id) ?? [],
lockedLandmarkIds:
intent?.keyLandmarks
.filter((entry) => entry.locked)
.map((entry) => entry.id) ?? [],
lockedConflictIds: [],
lockedFactionIds:
intent?.keyFactions
.filter((entry) => entry.locked)
.map((entry) => entry.id) ?? [],
};
}
export function hasMeaningfulCustomWorldCreatorIntent(
intent: CustomWorldCreatorIntent | null | undefined,
) {
return Boolean(
intent &&
(intent.rawSettingText ||
intent.worldHook ||
intent.themeKeywords.length > 0 ||
intent.toneDirectives.length > 0 ||
intent.playerPremise ||
intent.openingSituation ||
intent.coreConflicts.length > 0 ||
intent.keyFactions.length > 0 ||
intent.keyCharacters.length > 0 ||
intent.keyLandmarks.length > 0 ||
intent.iconicElements.length > 0 ||
intent.forbiddenDirectives.length > 0),
);
}
function buildAnchorLine(label: string, content: string) {
return content ? `${label}${content}` : '';
}
function buildCustomWorldCreatorIntentDisplayText(
intent: CustomWorldCreatorIntent | null | undefined,
) {
if (!hasMeaningfulCustomWorldCreatorIntent(intent)) {
return '';
}
const lines = [
intent?.worldHook ? `世界一句话:${intent.worldHook}` : '',
intent?.rawSettingText ? `补充设定:${intent.rawSettingText}` : '',
buildAnchorLine('主题关键词', intent?.themeKeywords.join('、') || ''),
buildAnchorLine('世界气质', intent?.toneDirectives.join('、') || ''),
buildAnchorLine('玩家是谁', intent?.playerPremise || ''),
buildAnchorLine('开局处境', intent?.openingSituation || ''),
buildAnchorLine('核心冲突', intent?.coreConflicts.join('、') || ''),
buildAnchorLine(
'关键势力',
intent?.keyFactions
.map((entry) =>
[entry.name, entry.publicGoal, entry.tension]
.filter(Boolean)
.join(' / '),
)
.filter(Boolean)
.join('') || '',
),
buildAnchorLine(
'关键角色',
intent?.keyCharacters
.map((entry) =>
[
entry.name,
entry.role,
entry.publicMask,
entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '',
]
.filter(Boolean)
.join(' / '),
)
.filter(Boolean)
.join('') || '',
),
buildAnchorLine(
'关键地点',
intent?.keyLandmarks
.map((entry) =>
[entry.name, entry.purpose, entry.mood].filter(Boolean).join(' / '),
)
.filter(Boolean)
.join('') || '',
),
buildAnchorLine('标志性要素', intent?.iconicElements.join('、') || ''),
buildAnchorLine('禁止事项', intent?.forbiddenDirectives.join('、') || ''),
].filter(Boolean);
return lines.join('\n');
}
export function buildCustomWorldCreatorIntentGenerationText(
intent: CustomWorldCreatorIntent | null | undefined,
) {
if (!hasMeaningfulCustomWorldCreatorIntent(intent)) {
return '';
}
const sections = [
buildAnchorLine('世界核心命题', intent?.worldHook || ''),
buildAnchorLine('补充设定原文', intent?.rawSettingText || ''),
buildAnchorLine('主题关键词', intent?.themeKeywords.join('、') || ''),
buildAnchorLine('情绪与气质', intent?.toneDirectives.join('、') || ''),
buildAnchorLine('玩家身份', intent?.playerPremise || ''),
buildAnchorLine('开局处境', intent?.openingSituation || ''),
buildAnchorLine('核心冲突', intent?.coreConflicts.join('、') || ''),
buildAnchorLine(
'关键势力锚点',
intent?.keyFactions
.map((entry) =>
[
entry.name,
entry.publicGoal ? `目标 ${entry.publicGoal}` : '',
entry.tension ? `张力 ${entry.tension}` : '',
entry.notes ? `补充 ${entry.notes}` : '',
]
.filter(Boolean)
.join(''),
)
.filter(Boolean)
.join('\n') || '',
),
buildAnchorLine(
'关键角色锚点',
intent?.keyCharacters
.map((entry) =>
[
entry.name,
entry.role ? `身份 ${entry.role}` : '',
entry.publicMask ? `表面 ${entry.publicMask}` : '',
entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '',
entry.relationToPlayer ? `与玩家 ${entry.relationToPlayer}` : '',
entry.notes ? `补充 ${entry.notes}` : '',
]
.filter(Boolean)
.join(''),
)
.filter(Boolean)
.join('\n') || '',
),
buildAnchorLine(
'关键地点锚点',
intent?.keyLandmarks
.map((entry) =>
[
entry.name,
entry.purpose ? `作用 ${entry.purpose}` : '',
entry.mood ? `氛围 ${entry.mood}` : '',
entry.secret ? `秘密 ${entry.secret}` : '',
]
.filter(Boolean)
.join(''),
)
.filter(Boolean)
.join('\n') || '',
),
buildAnchorLine('标志性要素', intent?.iconicElements.join('、') || ''),
buildAnchorLine('禁止事项', intent?.forbiddenDirectives.join('、') || ''),
].filter(Boolean);
return sections.join('\n\n');
}
function buildCharacterAnchorSummary(entry: CreatorCharacterSeed): ActorAnchor {
const summary = clampText(
[
entry.role,
entry.publicMask,
entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '',
entry.relationToPlayer ? `与玩家 ${entry.relationToPlayer}` : '',
]
.filter(Boolean)
.join(''),
72,
);
return {
id: entry.id,
name: entry.name || '未命名关键角色',
summary,
};
}
function buildLandmarkAnchorSummary(
entry: CreatorLandmarkSeed,
): LandmarkAnchor {
const summary = clampText(
[entry.purpose, entry.mood, entry.secret ? `秘密 ${entry.secret}` : '']
.filter(Boolean)
.join(''),
72,
);
return {
id: entry.id,
name: entry.name || '未命名关键地点',
summary,
};
}
export function buildCustomWorldAnchorPackFromIntent(
intent: CustomWorldCreatorIntent | null | undefined,
): CustomWorldAnchorPack | null {
if (!hasMeaningfulCustomWorldCreatorIntent(intent)) {
return null;
}
const lockedAnchorIds = [
...(intent?.keyCharacters
.filter((entry) => entry.locked)
.map((entry) => entry.id) ?? []),
...(intent?.keyLandmarks
.filter((entry) => entry.locked)
.map((entry) => entry.id) ?? []),
...(intent?.keyFactions
.filter((entry) => entry.locked)
.map((entry) => entry.id) ?? []),
];
return {
worldSummary: clampText(
intent?.worldHook || intent?.rawSettingText || '',
96,
),
creatorIntentSummary: clampText(
buildCustomWorldCreatorIntentDisplayText(intent),
240,
),
lockedAnchorIds,
keyConflictSummaries:
intent?.coreConflicts.map((entry) => clampText(entry, 48)) ?? [],
keyFactionSummaries:
intent?.keyFactions.map((entry) =>
clampText(
[entry.name, entry.publicGoal, entry.tension]
.filter(Boolean)
.join(''),
72,
),
) ?? [],
keyCharacterAnchors:
intent?.keyCharacters.map((entry) =>
buildCharacterAnchorSummary(entry),
) ?? [],
keyLandmarkAnchors:
intent?.keyLandmarks.map((entry) => buildLandmarkAnchorSummary(entry)) ??
[],
motifDirectives: [
...(intent?.themeKeywords ?? []),
...(intent?.toneDirectives ?? []),
...(intent?.iconicElements ?? []),
].slice(0, 12),
};
}

View File

@@ -0,0 +1,133 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildCompiledCustomWorldProfile,
validateGeneratedCustomWorldProfile,
} from './runtimeProfile.js';
function createPlayableNpc(index: number) {
return {
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}`],
};
}
function createStoryNpc(index: number) {
return {
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}`],
};
}
function createLandmark(index: number, storyNpcNames: string[]) {
return {
name: `场景${index + 1}`,
description: `场景描述${index + 1}`,
dangerLevel: 'medium',
sceneNpcNames: storyNpcNames,
connections: [
{
targetLandmarkName: `场景${((index + 1) % 10) + 1}`,
relativePosition: 'forward',
summary: '沿主路前行',
},
{
targetLandmarkName: `场景${((index + 9) % 10) + 1}`,
relativePosition: 'back',
summary: '回身可返',
},
],
};
}
test('buildCompiledCustomWorldProfile preserves runtime-critical generated fields on the server', () => {
const storyNpcs = Array.from({ length: 25 }, (_, index) =>
createStoryNpc(index),
);
const profile = buildCompiledCustomWorldProfile(
{
id: 'generated-world',
name: '测试世界',
subtitle: '副标题',
summary: '概述',
tone: '紧张、潮湿',
playerGoal: '先站稳,再查明真相',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '沉船商盟'],
coreConflicts: ['航道解释权正在争夺'],
creatorIntent: {
sourceMode: 'card',
rawSettingText: '',
worldHook: '一个被潮雾反复切开的边境世界。',
themeKeywords: ['潮雾', '边境'],
toneDirectives: ['紧张', '潮湿'],
playerPremise: '玩家是前巡夜人。',
openingSituation: '刚进城就卷入旧案。',
coreConflicts: ['旧案名单再次出现'],
keyFactions: [],
keyCharacters: [
{
id: 'creator-character-1',
name: '沈砺',
role: '灰炬向导',
publicMask: '看起来只是个带路人',
hiddenHook: '一直在查旧撤离线',
relationToPlayer: '会先怀疑玩家身份',
notes: '',
locked: true,
},
],
keyLandmarks: [],
iconicElements: ['裂潮灯塔'],
forbiddenDirectives: ['不要出现现代枪械'],
},
playableNpcs: Array.from({ length: 5 }, (_, index) =>
createPlayableNpc(index),
),
storyNpcs,
landmarks: Array.from({ length: 10 }, (_, index) =>
createLandmark(index, [
storyNpcs[index % storyNpcs.length]?.name ?? `场景角色${index + 1}`,
storyNpcs[(index + 1) % storyNpcs.length]?.name ??
`场景角色${index + 2}`,
storyNpcs[(index + 2) % storyNpcs.length]?.name ??
`场景角色${index + 3}`,
]),
),
},
'一个被潮雾反复切开的边境世界。',
);
assert.equal(profile.playableNpcs.length, 5);
assert.equal(profile.storyNpcs.length, 25);
assert.equal(profile.landmarks.length, 10);
assert.equal(profile.playableNpcs[0]?.templateCharacterId, 'sword-princess');
assert.ok(profile.playableNpcs[0]?.attributeProfile);
assert.ok(profile.storyNpcs[0]?.attributeProfile);
assert.equal(profile.scenarioPackId, 'scenario-pack:测试世界');
assert.equal(profile.campaignPackId, 'campaign-pack:测试世界');
assert.equal(profile.creatorIntent?.keyCharacters[0]?.name, '沈砺');
assert.ok(profile.anchorPack?.lockedAnchorIds.includes('creator-character-1'));
assert.ok(profile.landmarks.every((landmark) => landmark.sceneNpcIds.length >= 3));
validateGeneratedCustomWorldProfile(profile);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,389 @@
export type WorldType = 'WUXIA' | 'XIANXIA' | 'CUSTOM';
export type CustomWorldGenerationMode = 'fast' | 'full';
export type CustomWorldGenerationStatus = 'key_only' | 'complete';
export type CustomWorldCoverSourceType = 'default' | 'uploaded' | 'generated';
export interface CustomWorldCoverProfile {
sourceType: CustomWorldCoverSourceType;
imageSrc?: string | null;
characterRoleIds?: string[];
}
export interface CreatorFactionSeed {
id: string;
name: string;
publicGoal: string;
tension: string;
notes: string;
locked?: boolean;
}
export interface CreatorCharacterSeed {
id: string;
name: string;
role: string;
publicMask: string;
hiddenHook: string;
relationToPlayer: string;
notes: string;
locked?: boolean;
}
export interface CreatorLandmarkSeed {
id: string;
name: string;
purpose: string;
mood: string;
secret: string;
locked?: boolean;
}
export interface ActorAnchor {
id: string;
name: string;
summary: string;
}
export interface LandmarkAnchor {
id: string;
name: string;
summary: string;
}
export interface CustomWorldCreatorIntent {
sourceMode: 'freeform' | 'card';
rawSettingText: string;
worldHook: string;
themeKeywords: string[];
toneDirectives: string[];
playerPremise: string;
openingSituation: string;
coreConflicts: string[];
keyFactions: CreatorFactionSeed[];
keyCharacters: CreatorCharacterSeed[];
keyLandmarks: CreatorLandmarkSeed[];
iconicElements: string[];
forbiddenDirectives: string[];
}
export interface CustomWorldAnchorPack {
worldSummary: string;
creatorIntentSummary: string;
lockedAnchorIds: string[];
keyConflictSummaries: string[];
keyFactionSummaries: string[];
keyCharacterAnchors: ActorAnchor[];
keyLandmarkAnchors: LandmarkAnchor[];
motifDirectives: string[];
}
export interface CustomWorldLockState {
worldLockedFields: string[];
lockedCharacterIds: string[];
lockedLandmarkIds: string[];
lockedConflictIds: string[];
lockedFactionIds: string[];
}
export interface WorldAttributeSlot {
slotId: string;
name: string;
definition: string;
positiveSignals: string[];
negativeSignals: string[];
combatUseText: string;
socialUseText: string;
explorationUseText: string;
}
export interface WorldAttributeSchema {
id: string;
worldId: string;
schemaVersion: number;
schemaName: string;
generatedFrom: {
worldType: WorldType;
worldName: string;
settingSummary: string;
tone: string;
conflictCore: string;
};
slots: WorldAttributeSlot[];
}
export type AttributeVector = Record<string, number>;
export interface RoleAttributeEvidence {
slotId: string;
reason: string;
}
export interface RoleAttributeProfile {
schemaId: string;
values: AttributeVector;
topTraits: string[];
hiddenTraits?: string[];
evidence: RoleAttributeEvidence[];
}
export interface CharacterBackstoryChapter {
id: string;
title: string;
affinityRequired: number;
teaser: string;
content: string;
contextSnippet: string;
}
export interface CharacterBackstoryRevealConfig {
publicSummary: string;
privateChatUnlockAffinity: number;
chapters: CharacterBackstoryChapter[];
}
export interface ActorNarrativeProfile {
publicMask: string;
firstContactMask: string;
visibleLine: string;
hiddenLine: string;
contradiction: string;
debtOrBurden: string;
taboo: string;
immediatePressure: string;
relatedThreadIds: string[];
relatedScarIds: string[];
reactionHooks: string[];
}
export interface CustomWorldRoleSkill {
id: string;
name: string;
summary: string;
style: string;
actionPromptText?: string;
actionPreviewConfig?: Record<string, unknown>;
}
export interface CustomWorldRoleInitialItem {
id: string;
name: string;
category: string;
quantity: number;
rarity: string;
description: string;
tags: string[];
}
export interface CustomWorldRoleProfile {
id: string;
name: string;
title: string;
role: string;
description: string;
visualDescription?: string;
actionDescription?: string;
sceneVisualDescription?: string;
backstory: string;
personality: string;
motivation: string;
combatStyle: string;
initialAffinity: number;
relationshipHooks: string[];
tags: string[];
backstoryReveal: CharacterBackstoryRevealConfig;
skills: CustomWorldRoleSkill[];
initialItems: CustomWorldRoleInitialItem[];
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, unknown>;
attributeProfile?: RoleAttributeProfile;
narrativeProfile?: ActorNarrativeProfile | null;
}
export interface CustomWorldPlayableNpc extends CustomWorldRoleProfile {
templateCharacterId?: string;
}
export interface CustomWorldNpc extends CustomWorldRoleProfile {
visual?: Record<string, unknown>;
}
export interface CustomWorldItem {
id: string;
name: string;
category: string;
rarity: string;
description: string;
tags: string[];
}
export interface CustomWorldSceneConnection {
targetLandmarkId: string;
relativePosition: string;
summary: string;
}
export interface CustomWorldCampScene {
name: string;
description: string;
dangerLevel: string;
imageSrc?: string;
}
export interface CustomWorldLandmark {
id: string;
name: string;
description: string;
visualDescription?: string;
dangerLevel: string;
imageSrc?: string;
sceneNpcIds: string[];
connections: CustomWorldSceneConnection[];
narrativeResidues?:
| Array<{
summary?: string;
changeHint?: string;
hiddenTruth?: string;
}>
| null;
}
export interface ThemePack {
id: string;
displayName: string;
toneRange: string[];
institutionLexicon: string[];
tabooLexicon: string[];
artifactClasses: string[];
actorArchetypes: string[];
conflictForms: string[];
clueForms: string[];
namingPatterns: string[];
revealStyles: string[];
}
export interface StoryThread {
id: string;
title: string;
visibility: 'visible' | 'hidden';
summary: string;
conflictType: string;
stakes: string;
involvedFactionIds: string[];
involvedActorIds: string[];
relatedLocationIds: string[];
}
export interface StoryScar {
id: string;
title: string;
pastEvent: string;
publicResidue: string;
hiddenTruth: string;
relatedActorIds: string[];
relatedLocationIds: string[];
}
export interface StoryMotif {
id: string;
label: string;
semanticRole: string;
lexicalHints: string[];
}
export interface WorldStoryGraph {
visibleThreads: StoryThread[];
hiddenThreads: StoryThread[];
scars: StoryScar[];
motifs: StoryMotif[];
}
export interface CustomWorldProfile {
id: string;
settingText: string;
name: string;
subtitle: string;
summary: string;
tone: string;
playerGoal: string;
cover?: CustomWorldCoverProfile | null;
templateWorldType: WorldType;
compatibilityTemplateWorldType?: WorldType | null;
majorFactions: string[];
coreConflicts: string[];
attributeSchema: WorldAttributeSchema;
playableNpcs: CustomWorldPlayableNpc[];
storyNpcs: CustomWorldNpc[];
items: CustomWorldItem[];
camp?: CustomWorldCampScene | null;
landmarks: CustomWorldLandmark[];
themePack?: ThemePack | null;
storyGraph?: WorldStoryGraph | null;
knowledgeFacts?: Array<Record<string, unknown>> | null;
threadContracts?: Array<Record<string, unknown>> | null;
anchorContent?: Record<string, unknown> | null;
creatorIntent?: CustomWorldCreatorIntent | null;
anchorPack?: CustomWorldAnchorPack | null;
lockState?: CustomWorldLockState | null;
ownedSettingLayers?: Record<string, unknown> | null;
generationMode?: CustomWorldGenerationMode | null;
generationStatus?: CustomWorldGenerationStatus | null;
scenarioPackId?: string | null;
campaignPackId?: string | null;
}
export interface CustomWorldGenerationRoleOutline {
name: string;
title: string;
role: string;
description: string;
visualDescription?: string;
actionDescription?: string;
sceneVisualDescription?: string;
initialAffinity: number;
relationshipHooks: string[];
tags: string[];
}
export interface CustomWorldGenerationLandmarkConnectionOutline {
targetLandmarkName: string;
relativePosition: string;
summary: string;
}
export interface CustomWorldGenerationLandmarkOutline {
name: string;
description: string;
visualDescription?: string;
dangerLevel: string;
sceneNpcNames: string[];
connections: CustomWorldGenerationLandmarkConnectionOutline[];
}
export interface CustomWorldGenerationCampOutline {
name: string;
description: string;
dangerLevel: string;
}
export interface CustomWorldGenerationFramework {
settingText: string;
name: string;
subtitle: string;
summary: string;
tone: string;
playerGoal: string;
templateWorldType: WorldType;
compatibilityTemplateWorldType: WorldType;
majorFactions: string[];
coreConflicts: string[];
camp: CustomWorldGenerationCampOutline;
playableNpcs: CustomWorldGenerationRoleOutline[];
storyNpcs: CustomWorldGenerationRoleOutline[];
landmarks: CustomWorldGenerationLandmarkOutline[];
}
export type CustomWorldGenerationRoleBatchType = 'playable' | 'story';
export type CustomWorldGenerationRoleBatchStage = 'narrative' | 'dossier';

View File

@@ -37,6 +37,7 @@ type QuestStoryResolution = {
type JsonRecord = Record<string, unknown>;
type RuntimeGameState = Parameters<typeof appendStoryEngineCarrierMemory>[0];
type RuntimeQuestLogEntry = NonNullable<ReturnType<typeof buildQuestForEncounter>>;
type RuntimeNpcState = Parameters<
typeof markNpcFirstMeaningfulContactResolved
>[0];
@@ -83,11 +84,56 @@ function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function isObject(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readQuestId(request: RuntimeStoryActionRequest) {
const payload = readPayload(request);
return readString(payload.questId) || readString(request.action.targetId);
}
function readPendingQuestOffer(
currentStory: unknown,
npcKey: string,
): RuntimeQuestLogEntry | null {
if (!isObject(currentStory)) {
return null;
}
const npcChatState = isObject(currentStory.npcChatState)
? currentStory.npcChatState
: null;
const pendingQuestOffer = isObject(npcChatState?.pendingQuestOffer)
? npcChatState.pendingQuestOffer
: null;
const quest = isObject(pendingQuestOffer?.quest)
? pendingQuestOffer.quest
: null;
if (!quest) {
return null;
}
const pendingNpcId = readString(npcChatState?.npcId);
const questId = readString(quest.id);
const issuerNpcId = readString(quest.issuerNpcId);
if (!questId) {
return null;
}
if (pendingNpcId && pendingNpcId !== npcKey) {
return null;
}
if (issuerNpcId && issuerNpcId !== npcKey) {
return null;
}
return quest as RuntimeQuestLogEntry;
}
function ensureEncounterQuestContext(session: RuntimeSession) {
const state = session.rawGameState as unknown as RuntimeGameState;
const encounter = getNpcEncounter(session, state);
@@ -111,6 +157,7 @@ function ensureEncounterQuestContext(session: RuntimeSession) {
function resolveQuestAcceptAction(
session: RuntimeSession,
currentStory?: unknown,
): QuestStoryResolution {
const { state, encounter, npcKey, npcState } = ensureEncounterQuestContext(session);
const quests = Array.isArray(state.quests) ? state.quests : [];
@@ -119,18 +166,20 @@ function resolveQuestAcceptAction(
throw conflict('当前角色已经有未结清的委托。');
}
const quest = buildQuestForEncounter({
issuerNpcId: npcKey,
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene: state.currentScenePreset,
worldType: state.worldType,
currentQuests: quests.map((item) => ({
id: item.id,
issuerNpcId: item.issuerNpcId,
status: item.status,
})),
});
const quest =
readPendingQuestOffer(currentStory, npcKey) ??
buildQuestForEncounter({
issuerNpcId: npcKey,
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene: state.currentScenePreset,
worldType: state.worldType,
currentQuests: quests.map((item) => ({
id: item.id,
issuerNpcId: item.issuerNpcId,
status: item.status,
})),
});
if (!quest) {
throw conflict('当前场景缺少可落地的委托抓手,暂时无法接取任务。');
}
@@ -228,10 +277,13 @@ export function isSupportedQuestStoryFunctionId(functionId: string) {
export function resolveQuestStoryAction(
session: RuntimeSession,
request: RuntimeStoryActionRequest,
options: {
currentStory?: unknown;
} = {},
): QuestStoryResolution {
switch (request.action.functionId) {
case 'npc_quest_accept':
return resolveQuestAcceptAction(session);
return resolveQuestAcceptAction(session, options.currentStory);
case 'npc_quest_turn_in':
return resolveQuestTurnInAction(session, request);
default:

View File

@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildAvailableOptions,
buildLegacyCurrentStory,
loadRuntimeSession,
} from './runtimeSession.ts';
function createNpcSnapshot() {
return {
version: 2,
savedAt: '2026-04-19T00:00:00.000Z',
bottomTab: 'adventure',
currentStory: null,
gameState: {
worldType: 'WUXIA',
storyHistory: [],
currentEncounter: {
kind: 'npc',
id: 'npc_merchant_01',
npcName: '沈七',
npcDescription: '腰间挂着药囊的行商',
context: '受伤行商',
},
npcInteractionActive: true,
sceneHostileNpcs: [],
inBattle: false,
playerHp: 31,
playerMaxHp: 40,
playerMana: 9,
playerMaxMana: 16,
npcStates: {
npc_merchant_01: {
affinity: 46,
chattedCount: 1,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
companions: [],
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
quests: [],
playerInventory: [],
},
};
}
test('buildAvailableOptions attaches npc interaction metadata from the server runtime session', () => {
const session = loadRuntimeSession(
createNpcSnapshot() as Parameters<typeof loadRuntimeSession>[0],
'runtime-main',
);
const options = buildAvailableOptions(session);
assert.deepEqual(
options.find((option) => option.functionId === 'npc_chat')?.interaction,
{
kind: 'npc',
npcId: 'npc_merchant_01',
action: 'chat',
},
);
assert.deepEqual(
options.find((option) => option.functionId === 'npc_help')?.interaction,
{
kind: 'npc',
npcId: 'npc_merchant_01',
action: 'help',
},
);
});
test('buildLegacyCurrentStory preserves runtime interaction metadata on projected options', () => {
const session = loadRuntimeSession(
createNpcSnapshot() as Parameters<typeof loadRuntimeSession>[0],
'runtime-main',
);
const options = buildAvailableOptions(session);
const currentStory = buildLegacyCurrentStory('服务端已经生成了当前故事。', options);
assert.deepEqual(
currentStory.options.find((option) => option.functionId === 'npc_leave')
?.interaction,
{
kind: 'npc',
npcId: 'npc_merchant_01',
action: 'leave',
},
);
});

View File

@@ -1,6 +1,7 @@
import type {
RuntimeStoryEncounterViewModel,
RuntimeStoryChoicePayload,
RuntimeStoryEncounterViewModel,
RuntimeStoryOptionInteraction,
RuntimeStoryOptionView,
RuntimeStoryViewModel,
Task5RuntimeOptionScope,
@@ -180,8 +181,7 @@ const TASK6_RUNTIME_FUNCTION_ID_SET = new Set<string>(
TASK6_RUNTIME_FUNCTION_IDS,
);
export const TASK6_DEFERRED_FUNCTION_IDS = new Set<string>([
]);
export const TASK6_DEFERRED_FUNCTION_IDS = new Set<string>([]);
const FUNCTION_DEFINITIONS: Record<string, FunctionDefinition> = {
story_continue_adventure: {
@@ -406,13 +406,16 @@ function normalizeNpcState(value: unknown): RuntimeNpcState {
? cloneJson(rawState.stanceProfile)
: null,
revealedFacts: readArray(rawState.revealedFacts).filter(
(item): item is string => typeof item === 'string' && item.trim().length > 0,
(item): item is string =>
typeof item === 'string' && item.trim().length > 0,
),
knownAttributeRumors: readArray(rawState.knownAttributeRumors).filter(
(item): item is string => typeof item === 'string' && item.trim().length > 0,
(item): item is string =>
typeof item === 'string' && item.trim().length > 0,
),
seenBackstoryChapterIds: readArray(rawState.seenBackstoryChapterIds).filter(
(item): item is string => typeof item === 'string' && item.trim().length > 0,
(item): item is string =>
typeof item === 'string' && item.trim().length > 0,
),
};
}
@@ -456,7 +459,10 @@ function normalizeHostileNpc(value: unknown): RuntimeHostileNpc | null {
}
const maxHp = Math.max(1, Math.round(readNumber(rawNpc.maxHp, 1)));
const hp = Math.max(0, Math.min(maxHp, Math.round(readNumber(rawNpc.hp, maxHp))));
const hp = Math.max(
0,
Math.min(maxHp, Math.round(readNumber(rawNpc.hp, maxHp))),
);
return {
id,
@@ -489,7 +495,10 @@ function normalizeNpcStates(value: unknown) {
const rawStates = isObject(value) ? value : {};
return Object.fromEntries(
Object.entries(rawStates).map(([key, state]) => [key, normalizeNpcState(state)]),
Object.entries(rawStates).map(([key, state]) => [
key,
normalizeNpcState(state),
]),
) as Record<string, RuntimeNpcState>;
}
@@ -672,13 +681,14 @@ function buildBasicAttackBaseDamage(character: RuntimePlayerCharacter) {
}
function buildBattleDisabledOption(params: {
session: RuntimeSession;
functionId: string;
actionText?: string;
detailText?: string;
reason: string;
payload?: RuntimeStoryChoicePayload;
}) {
return buildOptionView(params.functionId, {
return buildOptionView(params.session, params.functionId, {
actionText: params.actionText,
detailText: params.detailText,
payload: params.payload,
@@ -687,6 +697,44 @@ function buildBattleDisabledOption(params: {
});
}
function buildOptionInteraction(
session: RuntimeSession,
functionId: string,
): RuntimeStoryOptionInteraction | undefined {
const encounter = session.currentEncounter;
if (encounter?.kind === 'npc') {
const npcId = getEncounterKey(encounter);
const npcActionMap: Record<string, RuntimeStoryOptionInteraction> = {
npc_chat: { kind: 'npc', npcId, action: 'chat' },
npc_fight: { kind: 'npc', npcId, action: 'fight' },
npc_help: { kind: 'npc', npcId, action: 'help' },
npc_leave: { kind: 'npc', npcId, action: 'leave' },
npc_preview_talk: { kind: 'npc', npcId, action: 'chat' },
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[functionId];
}
if (encounter?.kind === 'treasure') {
const treasureActionMap: Record<string, RuntimeStoryOptionInteraction> = {
treasure_secure: { kind: 'treasure', action: 'secure' },
treasure_inspect: { kind: 'treasure', action: 'inspect' },
treasure_leave: { kind: 'treasure', action: 'leave' },
};
return treasureActionMap[functionId];
}
return undefined;
}
function buildBattleItemSummary(
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>,
) {
@@ -711,44 +759,47 @@ function pickPreferredBattleItem(session: RuntimeSession) {
const cooldowns = getPlayerSkillCooldowns(session);
const hasCoolingSkill = Object.values(cooldowns).some((turns) => turns > 0);
const playerHpRatio = session.playerHp / Math.max(session.playerMaxHp, 1);
const playerManaRatio = session.playerMana / Math.max(session.playerMaxMana, 1);
const playerManaRatio =
session.playerMana / Math.max(session.playerMaxMana, 1);
return getBattleInventoryItems(session)
.filter((item) => item.quantity > 0 && isInventoryItemUsable(item))
.map((item) => {
const effect = resolveInventoryItemUseEffect(item, character);
if (!effect) {
return null;
}
return (
getBattleInventoryItems(session)
.filter((item) => item.quantity > 0 && isInventoryItemUsable(item))
.map((item) => {
const effect = resolveInventoryItemUseEffect(item, character);
if (!effect) {
return null;
}
const score =
effect.hpRestore * (playerHpRatio <= 0.45 ? 2.6 : 1.2) +
effect.manaRestore * (playerManaRatio <= 0.45 ? 2.2 : 0.9) +
effect.cooldownReduction * (hasCoolingSkill ? 18 : 6) +
effect.buildBuffs.length * 8;
const score =
effect.hpRestore * (playerHpRatio <= 0.45 ? 2.6 : 1.2) +
effect.manaRestore * (playerManaRatio <= 0.45 ? 2.2 : 0.9) +
effect.cooldownReduction * (hasCoolingSkill ? 18 : 6) +
effect.buildBuffs.length * 8;
return {
item,
effect,
score,
};
})
.filter(
(
candidate,
): candidate is {
item: RuntimeBattleInventoryItem;
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>;
score: number;
} => Boolean(candidate),
)
.sort(
(left, right) =>
right.score - left.score ||
right.effect.hpRestore - left.effect.hpRestore ||
right.effect.manaRestore - left.effect.manaRestore ||
left.item.name.localeCompare(right.item.name, 'zh-CN'),
)[0] ?? null;
return {
item,
effect,
score,
};
})
.filter(
(
candidate,
): candidate is {
item: RuntimeBattleInventoryItem;
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>;
score: number;
} => Boolean(candidate),
)
.sort(
(left, right) =>
right.score - left.score ||
right.effect.hpRestore - left.effect.hpRestore ||
right.effect.manaRestore - left.effect.manaRestore ||
left.item.name.localeCompare(right.item.name, 'zh-CN'),
)[0] ?? null
);
}
function buildBattleSkillOptions(session: RuntimeSession) {
@@ -762,7 +813,9 @@ function buildBattleSkillOptions(session: RuntimeSession) {
return character.skills.map((skill) => {
const remainingCooldown = cooldowns[skill.id] ?? 0;
const damage = resolvePlayerOutgoingDamageResult(
session.rawGameState as Parameters<typeof resolvePlayerOutgoingDamageResult>[0],
session.rawGameState as Parameters<
typeof resolvePlayerOutgoingDamageResult
>[0],
character,
skill.damage,
1,
@@ -776,6 +829,7 @@ function buildBattleSkillOptions(session: RuntimeSession) {
if (remainingCooldown > 0) {
return buildBattleDisabledOption({
session,
functionId: 'battle_use_skill',
actionText: skill.name,
detailText,
@@ -786,6 +840,7 @@ function buildBattleSkillOptions(session: RuntimeSession) {
if (skill.manaCost > session.playerMana) {
return buildBattleDisabledOption({
session,
functionId: 'battle_use_skill',
actionText: skill.name,
detailText,
@@ -794,7 +849,7 @@ function buildBattleSkillOptions(session: RuntimeSession) {
});
}
return buildOptionView('battle_use_skill', {
return buildOptionView(session, 'battle_use_skill', {
actionText: skill.name,
detailText,
payload: { skillId: skill.id },
@@ -807,7 +862,9 @@ function buildBattleActionOptions(session: RuntimeSession) {
const itemCandidate = pickPreferredBattleItem(session);
const basicAttackDamage = character
? resolvePlayerOutgoingDamageResult(
session.rawGameState as Parameters<typeof resolvePlayerOutgoingDamageResult>[0],
session.rawGameState as Parameters<
typeof resolvePlayerOutgoingDamageResult
>[0],
character,
buildBasicAttackBaseDamage(character),
1,
@@ -816,30 +873,31 @@ function buildBattleActionOptions(session: RuntimeSession) {
: 0;
return [
buildOptionView('battle_attack_basic', {
buildOptionView(session, 'battle_attack_basic', {
detailText:
basicAttackDamage > 0
? `不耗蓝 / 伤害 ${basicAttackDamage}`
: '不耗蓝的基础攻击',
}),
buildOptionView('battle_recover_breath', {
buildOptionView(session, 'battle_recover_breath', {
actionText: '恢复',
detailText: '回血 12 / 回蓝 9 / 冷却 -1',
}),
itemCandidate
? buildOptionView('inventory_use', {
? buildOptionView(session, 'inventory_use', {
actionText: `使用物品:${itemCandidate.item.name}`,
detailText: buildBattleItemSummary(itemCandidate.effect),
payload: { itemId: itemCandidate.item.id },
})
: buildBattleDisabledOption({
session,
functionId: 'inventory_use',
actionText: '使用物品',
detailText: '当前没有可直接结算的战斗消耗品',
reason: '暂无可用物品',
}),
...buildBattleSkillOptions(session),
buildOptionView('battle_escape_breakout'),
buildOptionView(session, 'battle_escape_breakout'),
] satisfies RuntimeStoryOptionView[];
}
@@ -875,7 +933,10 @@ export function loadRuntimeSession(
sceneHostileNpcs,
inBattle,
playerHp: Math.max(0, Math.round(readNumber(rawGameState.playerHp, 0))),
playerMaxHp: Math.max(1, Math.round(readNumber(rawGameState.playerMaxHp, 1))),
playerMaxHp: Math.max(
1,
Math.round(readNumber(rawGameState.playerMaxHp, 1)),
),
playerMana: Math.max(0, Math.round(readNumber(rawGameState.playerMana, 0))),
playerMaxMana: Math.max(
1,
@@ -953,6 +1014,7 @@ export function setEncounterNpcState(
}
function buildOptionView(
session: RuntimeSession,
functionId: string,
overrides: Partial<RuntimeStoryOptionView> = {},
): RuntimeStoryOptionView {
@@ -963,6 +1025,7 @@ function buildOptionView(
actionText: functionId,
detailText: '',
scope: 'story',
interaction: buildOptionInteraction(session, functionId),
...overrides,
};
}
@@ -972,6 +1035,7 @@ function buildOptionView(
actionText: definition.actionText,
detailText: definition.detailText,
scope: definition.scope,
interaction: buildOptionInteraction(session, functionId),
...overrides,
};
}
@@ -1033,38 +1097,42 @@ export function buildAvailableOptions(session: RuntimeSession) {
const npcState = getEncounterNpcState(session);
if (session.currentEncounter.hostile) {
return [
buildOptionView('npc_fight'),
buildOptionView('npc_leave'),
buildOptionView(session, 'npc_fight'),
buildOptionView(session, 'npc_leave'),
];
}
if (!session.npcInteractionActive) {
return [
buildOptionView('npc_preview_talk'),
buildOptionView('npc_fight'),
buildOptionView('npc_leave'),
buildOptionView(session, 'npc_preview_talk'),
buildOptionView(session, 'npc_fight'),
buildOptionView(session, 'npc_leave'),
];
}
const activeQuest = getActiveEncounterQuest(session);
const options = [
buildOptionView('npc_chat'),
buildOptionView('npc_help', npcState?.helpUsed
? {
disabled: true,
reason: '当前 NPC 的一次性援手已经用完了。',
}
: {}),
buildOptionView('npc_spar'),
buildOptionView('npc_fight'),
buildOptionView(session, 'npc_chat'),
buildOptionView(
session,
'npc_help',
npcState?.helpUsed
? {
disabled: true,
reason: '当前 NPC 的一次性援手已经用完了。',
}
: {},
),
buildOptionView(session, 'npc_spar'),
buildOptionView(session, 'npc_fight'),
];
if ((npcState?.inventory?.length ?? 0) > 0) {
options.push(buildOptionView('npc_trade'));
options.push(buildOptionView(session, 'npc_trade'));
}
if (hasGiftablePlayerInventory(session)) {
options.push(buildOptionView('npc_gift'));
options.push(buildOptionView(session, 'npc_gift'));
}
if (
@@ -1072,14 +1140,15 @@ export function buildAvailableOptions(session: RuntimeSession) {
(activeQuest.status === 'completed' ||
activeQuest.status === 'ready_to_turn_in')
) {
options.push(buildOptionView('npc_quest_turn_in'));
options.push(buildOptionView(session, 'npc_quest_turn_in'));
} else if (!activeQuest) {
options.push(buildOptionView('npc_quest_accept'));
options.push(buildOptionView(session, 'npc_quest_accept'));
}
if (npcState && !npcState.recruited && npcState.affinity >= 60) {
options.push(
buildOptionView(
session,
'npc_recruit',
session.companions.length >= MAX_TASK5_COMPANIONS
? {
@@ -1091,15 +1160,15 @@ export function buildAvailableOptions(session: RuntimeSession) {
);
}
options.push(buildOptionView('npc_leave'));
options.push(buildOptionView(session, 'npc_leave'));
return options;
}
if (session.currentEncounter?.kind === 'treasure') {
return [
buildOptionView('treasure_secure'),
buildOptionView('treasure_inspect'),
buildOptionView('treasure_leave'),
buildOptionView(session, 'treasure_secure'),
buildOptionView(session, 'treasure_inspect'),
buildOptionView(session, 'treasure_leave'),
];
}
@@ -1110,7 +1179,7 @@ export function buildAvailableOptions(session: RuntimeSession) {
'idle_explore_forward',
'idle_travel_next_scene',
'story_continue_adventure',
].map((functionId) => buildOptionView(functionId));
].map((functionId) => buildOptionView(session, functionId));
}
function buildEncounterViewModel(
@@ -1189,6 +1258,7 @@ export function buildLegacyCurrentStory(
text: option.actionText,
detailText: option.detailText,
priority: option.scope === 'npc' ? 3 : option.scope === 'combat' ? 2 : 1,
interaction: option.interaction,
runtimePayload: option.payload,
disabled: option.disabled,
disabledReason: option.reason,
@@ -1221,8 +1291,10 @@ export function syncRawGameState(session: RuntimeSession) {
session.rawGameState.npcStates = cloneJson(session.npcStates);
session.rawGameState.companions = cloneJson(session.companions);
session.rawGameState.currentNpcBattleMode = session.currentNpcBattleMode;
session.rawGameState.currentNpcBattleOutcome = session.currentNpcBattleOutcome;
session.rawGameState.currentBattleNpcId = session.currentEncounter?.id ?? null;
session.rawGameState.currentNpcBattleOutcome =
session.currentNpcBattleOutcome;
session.rawGameState.currentBattleNpcId =
session.currentEncounter?.id ?? null;
session.rawGameState.activeCombatEffects = [];
session.rawGameState.playerActionMode = 'idle';
session.rawGameState.scrollWorld = false;

View File

@@ -160,7 +160,15 @@ function withBearer(token: string, init: TestRequestInit = {}) {
} satisfies TestRequestInit;
}
async function putSnapshot(baseUrl: string, token: string, gameState: unknown) {
async function putSnapshot(
baseUrl: string,
token: string,
gameState: unknown,
currentStory: unknown = {
text: '初始化剧情',
options: [],
},
) {
const response = await httpRequest(
`${baseUrl}/api/runtime/save/snapshot`,
withBearer(token, {
@@ -168,10 +176,7 @@ async function putSnapshot(baseUrl: string, token: string, gameState: unknown) {
body: JSON.stringify({
gameState,
bottomTab: 'adventure',
currentStory: {
text: '初始化剧情',
options: [],
},
currentStory,
}),
}),
);
@@ -240,6 +245,73 @@ function createTask6GameState(overrides: Record<string, unknown> = {}) {
};
}
function createPendingQuestOfferCurrentStory(quest: Record<string, unknown>) {
return {
text: '巡路人终于把真正的委托说了出来。',
options: [
{
functionId: 'npc_chat_quest_offer_view',
actionText: '查看任务',
text: '查看任务',
detailText: '',
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
{
functionId: 'npc_chat_quest_offer_replace',
actionText: '更换任务',
text: '更换任务',
detailText: '',
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
{
functionId: 'npc_chat_quest_offer_abandon',
actionText: '放弃任务',
text: '放弃任务',
detailText: '',
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
],
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: '巡路人',
text: '这件事我只想托给你。',
},
],
npcChatState: {
npcId: 'npc_scout_01',
npcName: '巡路人',
turnCount: 2,
customInputPlaceholder: '输入你想对 TA 说的话',
pendingQuestOffer: {
quest,
},
},
};
}
const QUEST_BATTLE_SCENE = {
id: 'quest-bridge',
name: '断桥口',
@@ -325,6 +397,11 @@ test('runtime story actions resolve npc chat on the server and persist updated a
} | null;
availableOptions: Array<{
functionId: string;
interaction?: {
kind: string;
npcId?: string;
action?: string;
};
}>;
};
presentation: {
@@ -344,15 +421,28 @@ test('runtime story actions resolve npc chat on the server and persist updated a
(option) => option.functionId === 'npc_help',
),
);
assert.deepEqual(
payload.viewModel.availableOptions.find(
(option) => option.functionId === 'npc_help',
)?.interaction,
{
kind: 'npc',
npcId: 'npc_merchant_01',
action: 'help',
},
);
assert.ok(
payload.patches.some((patch) => patch.type === 'npc_affinity_changed'),
);
const snapshotResponse = await httpRequest(`${baseUrl}/api/runtime/save/snapshot`, {
headers: {
Authorization: `Bearer ${entry.token}`,
const snapshotResponse = await httpRequest(
`${baseUrl}/api/runtime/save/snapshot`,
{
headers: {
Authorization: `Bearer ${entry.token}`,
},
},
});
);
const snapshotPayload = (await snapshotResponse.json()) as {
gameState: {
runtimeActionVersion: number;
@@ -369,14 +459,95 @@ test('runtime story actions resolve npc chat on the server and persist updated a
assert.equal(snapshotResponse.status, 200);
assert.equal(snapshotPayload.gameState.runtimeActionVersion, 1);
assert.equal(snapshotPayload.gameState.npcStates.npc_merchant_01.affinity, 52);
assert.equal(
snapshotPayload.gameState.npcStates.npc_merchant_01.affinity,
52,
);
assert.match(snapshotPayload.currentStory.text, //u);
});
});
test('runtime story state exposes npc interaction metadata directly from the server option builder', async () => {
await withTestServer('npc-state-options', async ({ baseUrl }) => {
const entry = await authEntry(baseUrl, 'story_npc_state', 'secret123');
await putSnapshot(
baseUrl,
entry.token,
createTask6GameState({
currentEncounter: {
kind: 'npc',
id: 'npc_merchant_01',
npcName: '沈七',
npcDescription: '腰间挂着药囊的行商',
context: '受伤行商',
},
npcInteractionActive: true,
npcStates: {
npc_merchant_01: {
affinity: 46,
chattedCount: 1,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
}),
);
const response = await httpRequest(
`${baseUrl}/api/runtime/story/state/runtime-main`,
{
headers: {
Authorization: `Bearer ${entry.token}`,
},
},
);
const payload = (await response.json()) as {
viewModel: {
availableOptions: Array<{
functionId: string;
interaction?: {
kind: string;
npcId?: string;
action?: string;
};
}>;
};
};
assert.equal(response.status, 200);
assert.deepEqual(
payload.viewModel.availableOptions.find(
(option) => option.functionId === 'npc_chat',
)?.interaction,
{
kind: 'npc',
npcId: 'npc_merchant_01',
action: 'chat',
},
);
assert.deepEqual(
payload.viewModel.availableOptions.find(
(option) => option.functionId === 'npc_help',
)?.interaction,
{
kind: 'npc',
npcId: 'npc_merchant_01',
action: 'help',
},
);
});
});
test('runtime story actions resolve combat finishers on the server and collapse the battle state', async () => {
await withTestServer('combat-finisher', async ({ baseUrl }) => {
const entry = await authEntry(baseUrl, 'story_combat_finisher', 'secret123');
const entry = await authEntry(
baseUrl,
'story_combat_finisher',
'secret123',
);
await putSnapshot(
baseUrl,
@@ -457,7 +628,10 @@ test('runtime story actions resolve combat finishers on the server and collapse
assert.equal(response.status, 200);
assert.equal(payload.viewModel.encounter, null);
assert.equal(payload.viewModel.status.inBattle, false);
assert.equal(payload.viewModel.status.currentNpcBattleOutcome, 'fight_victory');
assert.equal(
payload.viewModel.status.currentNpcBattleOutcome,
'fight_victory',
);
assert.equal(payload.presentation.battle?.outcome, 'victory');
assert.ok((payload.presentation.battle?.damageDealt ?? 0) >= 12);
assert.ok(
@@ -466,11 +640,14 @@ test('runtime story actions resolve combat finishers on the server and collapse
),
);
const snapshotResponse = await httpRequest(`${baseUrl}/api/runtime/save/snapshot`, {
headers: {
Authorization: `Bearer ${entry.token}`,
const snapshotResponse = await httpRequest(
`${baseUrl}/api/runtime/save/snapshot`,
{
headers: {
Authorization: `Bearer ${entry.token}`,
},
},
});
);
const snapshotPayload = (await snapshotResponse.json()) as {
gameState: {
inBattle: boolean;
@@ -484,7 +661,10 @@ test('runtime story actions resolve combat finishers on the server and collapse
assert.equal(snapshotPayload.gameState.inBattle, false);
assert.equal(snapshotPayload.gameState.currentEncounter, null);
assert.deepEqual(snapshotPayload.gameState.sceneHostileNpcs, []);
assert.equal(snapshotPayload.gameState.currentNpcBattleOutcome, 'fight_victory');
assert.equal(
snapshotPayload.gameState.currentNpcBattleOutcome,
'fight_victory',
);
});
});
@@ -634,7 +814,11 @@ test('runtime story state exposes the single-action combat option pool with runt
test('runtime story actions resolve battle_use_skill as a single ongoing combat turn', async () => {
await withTestServer('combat-use-skill', async ({ baseUrl }) => {
const entry = await authEntry(baseUrl, 'story_combat_use_skill', 'secret123');
const entry = await authEntry(
baseUrl,
'story_combat_use_skill',
'secret123',
);
const playerCharacter = {
...requirePlayerCharacter(),
skills: [
@@ -768,13 +952,19 @@ test('runtime story actions resolve battle_use_skill as a single ongoing combat
assert.equal(payload.serverVersion, 1);
assert.equal(payload.presentation.battle?.outcome, 'ongoing');
assert.ok((payload.presentation.battle?.damageDealt ?? 0) > 0);
assert.equal(payload.presentation.storyText, payload.presentation.resultText);
assert.equal(
payload.presentation.storyText,
payload.presentation.resultText,
);
assert.match(payload.presentation.storyText, //u);
assert.equal(payload.viewModel.status.inBattle, true);
assert.equal(payload.viewModel.player.mana, 5);
assert.equal(payload.snapshot.gameState.playerMana, 5);
assert.equal(payload.snapshot.gameState.playerSkillCooldowns.slash, 2);
assert.equal(payload.snapshot.gameState.activeBuildBuffs[0]?.id, 'slash:buff');
assert.equal(
payload.snapshot.gameState.activeBuildBuffs[0]?.id,
'slash:buff',
);
assert.ok(
payload.patches.some(
(patch) =>
@@ -797,7 +987,11 @@ test('runtime story actions resolve battle_use_skill as a single ongoing combat
test('runtime story actions resolve inventory_use and persist updated resources', async () => {
await withTestServer('task6-inventory-use', async ({ baseUrl }) => {
const entry = await authEntry(baseUrl, 'story_task6_inventory', 'secret123');
const entry = await authEntry(
baseUrl,
'story_task6_inventory',
'secret123',
);
await putSnapshot(
baseUrl,
@@ -957,14 +1151,24 @@ test('runtime story actions resolve equipment_equip and persist updated loadout'
assert.ok(payload.viewModel.player.maxHp > 40);
assert.match(payload.presentation.storyText, //u);
assert.equal(payload.snapshot.gameState.playerInventory.length, 0);
assert.equal(payload.snapshot.gameState.playerEquipment.armor?.id, 'ward-mail');
assert.equal(payload.snapshot.gameState.playerEquipment.armor?.name, '镇岳甲');
assert.equal(
payload.snapshot.gameState.playerEquipment.armor?.id,
'ward-mail',
);
assert.equal(
payload.snapshot.gameState.playerEquipment.armor?.name,
'镇岳甲',
);
});
});
test('runtime story actions resolve npc_trade buy transactions on the server', async () => {
await withTestServer('task6-trade-buy', async ({ baseUrl }) => {
const entry = await authEntry(baseUrl, 'story_task6_trade_buy', 'secret123');
const entry = await authEntry(
baseUrl,
'story_task6_trade_buy',
'secret123',
);
await putSnapshot(
baseUrl,
@@ -1044,7 +1248,8 @@ test('runtime story actions resolve npc_trade buy transactions on the server', a
assert.equal(payload.snapshot.gameState.playerInventory[0]?.name, '回气散');
assert.equal(payload.snapshot.gameState.playerInventory[0]?.quantity, 2);
assert.equal(
payload.snapshot.gameState.npcStates.npc_merchant_02.inventory[0]?.quantity,
payload.snapshot.gameState.npcStates.npc_merchant_02.inventory[0]
?.quantity,
1,
);
});
@@ -1124,15 +1329,26 @@ test('runtime story actions resolve npc_gift and persist affinity changes', asyn
assert.equal(response.status, 200);
assert.equal(payload.snapshot.gameState.playerInventory.length, 0);
assert.ok(payload.snapshot.gameState.npcStates.npc_merchant_03.affinity > 22);
assert.equal(payload.snapshot.gameState.npcStates.npc_merchant_03.giftsGiven, 1);
assert.ok(payload.patches.some((patch) => patch.type === 'npc_affinity_changed'));
assert.ok(
payload.snapshot.gameState.npcStates.npc_merchant_03.affinity > 22,
);
assert.equal(
payload.snapshot.gameState.npcStates.npc_merchant_03.giftsGiven,
1,
);
assert.ok(
payload.patches.some((patch) => patch.type === 'npc_affinity_changed'),
);
});
});
test('runtime story actions resolve npc_quest_accept and persist accepted quests', async () => {
await withTestServer('task6-quest-accept', async ({ baseUrl }) => {
const entry = await authEntry(baseUrl, 'story_task6_quest_accept', 'secret123');
const entry = await authEntry(
baseUrl,
'story_task6_quest_accept',
'secret123',
);
await putSnapshot(
baseUrl,
@@ -1191,13 +1407,129 @@ test('runtime story actions resolve npc_quest_accept and persist accepted quests
assert.equal(response.status, 200);
assert.equal(payload.snapshot.gameState.quests.length, 1);
assert.equal(payload.snapshot.gameState.quests[0]?.issuerNpcId, 'npc_scout_01');
assert.equal(
payload.snapshot.gameState.quests[0]?.issuerNpcId,
'npc_scout_01',
);
assert.equal(payload.snapshot.gameState.quests[0]?.status, 'active');
assert.equal(payload.snapshot.gameState.runtimeStats.questsAccepted, 1);
assert.match(payload.presentation.storyText, //u);
});
});
test('runtime story actions accept pending npc quest offers from saved chat state', async () => {
await withTestServer('task6-quest-accept-pending-offer', async ({ baseUrl }) => {
const entry = await authEntry(
baseUrl,
'story_q_accept_pending',
'secret123',
);
const seededQuest = buildQuestForEncounter({
issuerNpcId: 'npc_scout_01',
issuerNpcName: '巡路人',
roleText: '巡路人',
scene: QUEST_BATTLE_SCENE,
worldType: 'WUXIA',
currentQuests: [],
});
assert.ok(seededQuest);
const pendingQuest = {
...seededQuest,
id: 'quest-pending-offer',
};
await putSnapshot(
baseUrl,
entry.token,
createTask6GameState({
currentEncounter: {
kind: 'npc',
id: 'npc_scout_01',
npcName: '巡路人',
npcDescription: '熟悉桥口风向的探子',
context: '巡路人',
characterId: 'scout-quest',
},
currentScenePreset: QUEST_BATTLE_SCENE,
npcInteractionActive: true,
npcStates: {
npc_scout_01: {
affinity: 16,
chattedCount: 0,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
}),
createPendingQuestOfferCurrentStory(pendingQuest),
);
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_quest_accept',
},
}),
}),
);
const payload = (await response.json()) as {
snapshot: {
gameState: {
quests: Array<{ id: string; issuerNpcId: string; status: string }>;
};
currentStory: {
displayMode?: string;
options?: Array<{ actionText?: string }>;
dialogue?: Array<{ speaker?: string; text?: string }>;
npcChatState?: {
pendingQuestOffer?: unknown;
};
};
};
};
assert.equal(response.status, 200);
assert.equal(payload.snapshot.gameState.quests.length, 1);
assert.equal(
payload.snapshot.gameState.quests[0]?.id,
'quest-pending-offer',
);
assert.equal(
payload.snapshot.gameState.quests[0]?.issuerNpcId,
'npc_scout_01',
);
assert.equal(payload.snapshot.currentStory.displayMode, 'dialogue');
assert.equal(
payload.snapshot.currentStory.npcChatState?.pendingQuestOffer ?? null,
null,
);
assert.deepEqual(
payload.snapshot.currentStory.options?.map((option) => option.actionText),
[
'这件事里你最担心哪一步',
'我回来时你最想先知道什么',
'除了这份委托,你还想提醒我什么',
],
);
assert.equal(
payload.snapshot.currentStory.dialogue?.at(-2)?.text,
'这件事我愿意接下,你把关键要点交给我。',
);
assert.match(
payload.snapshot.currentStory.dialogue?.at(-1)?.text ?? '',
//u,
);
});
});
test('runtime story actions progress quests from combat victories and npc turn-ins', async () => {
await withTestServer('task6-quest-progress-turnin', async ({ baseUrl }) => {
const entry = await authEntry(baseUrl, 'story_qp_turnin', 'secret123');
@@ -1357,10 +1689,15 @@ test('runtime story actions progress quests from combat victories and npc turn-i
};
assert.equal(turnInResponse.status, 200);
assert.equal(turnInPayload.snapshot.gameState.quests[0]?.status, 'turned_in');
assert.equal(
turnInPayload.snapshot.gameState.quests[0]?.status,
'turned_in',
);
assert.ok(turnInPayload.snapshot.gameState.playerCurrency > 12);
assert.ok(turnInPayload.snapshot.gameState.playerInventory.length > 0);
assert.ok(turnInPayload.snapshot.gameState.npcStates.npc_bandit_01.affinity > 6);
assert.ok(
turnInPayload.snapshot.gameState.npcStates.npc_bandit_01.affinity > 6,
);
});
});

View File

@@ -14,16 +14,17 @@ import {
} from '../ai/chatPromptBuilders.js';
import { generateNextStoryFromOrchestrator } from '../ai/storyOrchestrator.js';
import { resolveCombatAction } from '../combat/combatResolutionService.js';
import { isSupportedInventoryStoryFunctionId,resolveInventoryStoryAction } from '../inventory/inventoryStoryActionService.js';
import {
isSupportedInventoryStoryFunctionId,
resolveInventoryStoryAction,
} from '../inventory/inventoryStoryActionService.js';
import {
ensureNpcInventorySessionState,
isSupportedNpcInventoryStoryFunctionId,
resolveNpcInventoryStoryAction,
} from '../inventory/npcInventoryStoryActionService.js';
import { resolveNpcInteraction } from '../npc/npcInteractionService.js';
import {
applyQuestSignalsForResolvedAction,
} from '../quest/questRuntimeSignalService.js';
import { applyQuestSignalsForResolvedAction } from '../quest/questRuntimeSignalService.js';
import {
isSupportedQuestStoryFunctionId,
resolveQuestStoryAction,
@@ -92,7 +93,10 @@ const DEFAULT_STORY_OPTION_VISUALS = {
monsterChanges: [],
} as const;
function resolveActionText(defaultText: string, request: RuntimeStoryActionRequest) {
function resolveActionText(
defaultText: string,
request: RuntimeStoryActionRequest,
) {
const payload = request.action.payload;
const optionText =
payload && typeof payload.optionText === 'string'
@@ -114,54 +118,14 @@ 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,
) {
function buildStoryOptionFromRuntimeOption(option: RuntimeStoryOptionView) {
return {
functionId: option.functionId,
actionText: option.actionText,
text: option.actionText,
detailText: option.detailText,
visuals: DEFAULT_STORY_OPTION_VISUALS,
interaction: buildStoryOptionInteraction(session, option),
interaction: option.interaction,
runtimePayload: option.payload,
disabled: option.disabled,
disabledReason: option.reason,
@@ -169,10 +133,10 @@ function buildStoryOptionFromRuntimeOption(
}
function buildStoryOptionsFromRuntimeOptions(
session: RuntimeSession,
_session: RuntimeSession,
options: RuntimeStoryOptionView[],
) {
return options.map((option) => buildStoryOptionFromRuntimeOption(session, option));
return options.map((option) => buildStoryOptionFromRuntimeOption(option));
}
function escapeRegExp(value: string) {
@@ -284,10 +248,9 @@ function buildDialogueCurrentStory(params: {
}) {
return {
text: params.text,
options: buildStoryOptionsFromRuntimeOptions(
params.session,
[CONTINUE_ADVENTURE_OPTION],
),
options: buildStoryOptionsFromRuntimeOptions(params.session, [
CONTINUE_ADVENTURE_OPTION,
]),
displayMode: 'dialogue',
dialogue: parseDialogueTurns(params.text, params.npcName),
streaming: false,
@@ -298,7 +261,150 @@ function buildDialogueCurrentStory(params: {
} satisfies JsonRecord;
}
function buildStoryPromptContext(session: RuntimeSession, extras: JsonRecord = {}) {
function readDialogueTurns(currentStory: unknown) {
if (!isObject(currentStory) || !Array.isArray(currentStory.dialogue)) {
return [];
}
return currentStory.dialogue.filter(isObject).map((turn) => ({ ...turn }));
}
function readPendingQuestOfferContext(params: {
currentStory: unknown;
encounterId: string;
}) {
if (!isObject(params.currentStory)) {
return null;
}
const npcChatState = isObject(params.currentStory.npcChatState)
? params.currentStory.npcChatState
: null;
const pendingQuestOffer = isObject(npcChatState?.pendingQuestOffer)
? npcChatState.pendingQuestOffer
: null;
const quest = isObject(pendingQuestOffer?.quest)
? pendingQuestOffer.quest
: null;
if (!quest) {
return null;
}
const npcId = readString(npcChatState?.npcId);
if (npcId && npcId !== params.encounterId) {
return null;
}
return {
dialogue: readDialogueTurns(params.currentStory),
turnCount:
typeof npcChatState?.turnCount === 'number' &&
Number.isFinite(npcChatState.turnCount)
? Math.max(0, Math.round(npcChatState.turnCount))
: 0,
customInputPlaceholder:
readString(npcChatState?.customInputPlaceholder) ||
'输入你想对 TA 说的话',
quest,
};
}
function buildNpcChatSuggestionOption(
encounter: RuntimeSession['currentEncounter'] & { kind: 'npc' },
actionText: string,
) {
return buildStoryOptionFromRuntimeOption({
functionId: 'npc_chat',
actionText,
detailText: '',
scope: 'npc',
interaction: {
kind: 'npc',
npcId: encounter.id,
action: 'chat',
},
});
}
function buildQuestAcceptedNpcReplyText(quest: JsonRecord) {
const activeStepId = readString(quest.activeStepId);
const activeStep = readArray(quest.steps)
.filter(isObject)
.find((step) => readString(step.id) === activeStepId);
const revealText = readString(activeStep?.revealText);
const summary = readString(quest.summary);
if (revealText) {
return `那就拜托你了。${revealText}`;
}
return `那就拜托你了。${summary || '这份委托的关键要点我已经交给你。'}`;
}
function buildQuestAcceptedSuggestionOptions(
encounter: RuntimeSession['currentEncounter'] & { kind: 'npc' },
) {
return [
'这件事里你最担心哪一步',
'我回来时你最想先知道什么',
'除了这份委托,你还想提醒我什么',
].map((actionText) => buildNpcChatSuggestionOption(encounter, actionText));
}
function buildPendingQuestAcceptedCurrentStory(params: {
session: RuntimeSession;
currentStory: unknown;
}) {
const encounter = params.session.currentEncounter;
if (!encounter || encounter.kind !== 'npc') {
return null;
}
const pendingOffer = readPendingQuestOfferContext({
currentStory: params.currentStory,
encounterId: encounter.id,
});
if (!pendingOffer) {
return null;
}
const dialogue = [
...pendingOffer.dialogue,
{
speaker: 'player',
text: '这件事我愿意接下,你把关键要点交给我。',
},
{
speaker: 'npc',
speakerName: encounter.npcName,
text: buildQuestAcceptedNpcReplyText(pendingOffer.quest),
},
];
return {
text: dialogue
.map((turn) => readString(turn.text))
.filter(Boolean)
.join('\n'),
options: buildQuestAcceptedSuggestionOptions(encounter),
displayMode: 'dialogue',
dialogue,
streaming: false,
npcChatState: {
npcId: encounter.id,
npcName: encounter.npcName,
turnCount: pendingOffer.turnCount,
customInputPlaceholder: pendingOffer.customInputPlaceholder,
pendingQuestOffer: null,
},
} satisfies JsonRecord;
}
function buildStoryPromptContext(
session: RuntimeSession,
extras: JsonRecord = {},
) {
const scenePreset = isObject(session.rawGameState.currentScenePreset)
? session.rawGameState.currentScenePreset
: null;
@@ -409,11 +515,19 @@ function buildOpeningCampChatContext(session: RuntimeSession) {
for (let index = 0; index < session.storyHistory.length - 1; index += 1) {
const entry = session.storyHistory[index];
if (!entry || entry.historyRole !== 'action' || entry.text !== openingActionText) {
if (
!entry ||
entry.historyRole !== 'action' ||
entry.text !== openingActionText
) {
continue;
}
for (let nextIndex = index + 1; nextIndex < session.storyHistory.length; nextIndex += 1) {
for (
let nextIndex = index + 1;
nextIndex < session.storyHistory.length;
nextIndex += 1
) {
const nextEntry = session.storyHistory[nextIndex];
if (!nextEntry) {
continue;
@@ -517,7 +631,12 @@ async function generateNpcDialoguePayload(params: {
const playerCharacter = isObject(params.session.rawGameState.playerCharacter)
? params.session.rawGameState.playerCharacter
: null;
if (!encounter || encounter.kind !== 'npc' || !playerCharacter || !params.session.worldType) {
if (
!encounter ||
encounter.kind !== 'npc' ||
!playerCharacter ||
!params.session.worldType
) {
return null;
}
@@ -533,7 +652,9 @@ async function generateNpcDialoguePayload(params: {
worldType: params.session.worldType,
character: playerCharacter,
encounter: params.session.rawGameState.currentEncounter ?? {},
monsters: readArray(params.session.rawGameState.sceneHostileNpcs).filter(isObject),
monsters: readArray(
params.session.rawGameState.sceneHostileNpcs,
).filter(isObject),
history,
context: buildStoryPromptContext(params.session, {
...buildOpeningCampChatContext(params.session),
@@ -637,7 +758,8 @@ function resolveStoryFlowAction(
case 'story_continue_adventure':
return {
actionText: '继续推进冒险',
resultText: '你没有把节奏停下来,而是顺着当前局势继续向前推进了这一段故事。',
resultText:
'你没有把节奏停下来,而是顺着当前局势继续向前推进了这一段故事。',
patches: [normalizeStatusPatch(session)],
};
case 'story_opening_camp_dialogue': {
@@ -669,7 +791,8 @@ function resolveStoryFlowAction(
return {
actionText: '交换开场判断',
resultText: '你先把眼前局势梳理了一遍,为后续真正推进对话和行动留出了空间。',
resultText:
'你先把眼前局势梳理了一遍,为后续真正推进对话和行动留出了空间。',
patches: [normalizeStatusPatch(session)],
};
}
@@ -677,7 +800,8 @@ function resolveStoryFlowAction(
clearEncounterState(session);
return {
actionText: '返回营地',
resultText: '你主动结束了当前遭遇,把这轮流程带回了更安全的营地节奏里。',
resultText:
'你主动结束了当前遭遇,把这轮流程带回了更安全的营地节奏里。',
patches: [
normalizeStatusPatch(session),
{
@@ -689,13 +813,15 @@ function resolveStoryFlowAction(
case 'idle_call_out':
return {
actionText: '主动出声试探',
resultText: '你的喊话打破了当前的静场,周围潜着的动静也因此更难继续藏着。',
resultText:
'你的喊话打破了当前的静场,周围潜着的动静也因此更难继续藏着。',
patches: [normalizeStatusPatch(session)],
};
case 'idle_explore_forward':
return {
actionText: '继续向前探索',
resultText: '你没有停在原地,而是继续往前压,把下一段遭遇主动推到了自己面前。',
resultText:
'你没有停在原地,而是继续往前压,把下一段遭遇主动推到了自己面前。',
patches: [normalizeStatusPatch(session)],
};
case 'idle_observe_signs':
@@ -706,7 +832,10 @@ function resolveStoryFlowAction(
};
case 'idle_rest_focus':
session.playerHp = Math.min(session.playerMaxHp, session.playerHp + 8);
session.playerMana = Math.min(session.playerMaxMana, session.playerMana + 6);
session.playerMana = Math.min(
session.playerMaxMana,
session.playerMana + 6,
);
return {
actionText: '原地调息',
resultText: '你把呼吸慢下来重新稳住节奏,生命和灵力都回上来一点。',
@@ -804,7 +933,9 @@ export async function resolveRuntimeStoryAction(params: {
} else if (isSupportedNpcInventoryStoryFunctionId(functionId)) {
resolution = resolveNpcInventoryStoryAction(session, params.request);
} else if (isSupportedQuestStoryFunctionId(functionId)) {
resolution = resolveQuestStoryAction(session, params.request);
resolution = resolveQuestStoryAction(session, params.request, {
currentStory: hydratedSnapshot.currentStory,
});
} else if (isSupportedTreasureStoryFunctionId(functionId)) {
resolution = resolveTreasureStoryAction(session, params.request);
} else if (isStoryFunctionId(functionId)) {
@@ -836,9 +967,21 @@ export async function resolveRuntimeStoryAction(params: {
syncRawGameState(session);
ensureNpcInventorySessionState(session);
let options = buildAvailableOptions(session);
let savedCurrentStory: JsonRecord = buildLegacyCurrentStory(storyText, options);
let savedCurrentStory: JsonRecord = buildLegacyCurrentStory(
storyText,
options,
);
const pendingQuestAcceptedCurrentStory =
functionId === 'npc_quest_accept'
? buildPendingQuestAcceptedCurrentStory({
session,
currentStory: hydratedSnapshot.currentStory,
})
: null;
if (
if (pendingQuestAcceptedCurrentStory) {
savedCurrentStory = pendingQuestAcceptedCurrentStory;
} else if (
params.llmClient &&
(functionId === 'npc_chat' || functionId === 'story_opening_camp_dialogue')
) {