This commit is contained in:
2026-04-19 20:33:18 +08:00
parent 692643136f
commit 67c584b4df
123 changed files with 11898 additions and 4082 deletions

View File

@@ -125,11 +125,13 @@ function describeAffinityShift(affinityDelta: number) {
}
function buildFallbackNpcChatSuggestions(playerMessage: string) {
const topic = playerMessage.trim() || '刚才那句';
const topic = Array.from(playerMessage.trim() || '刚才那句')
.slice(0, 8)
.join('');
return [
`顺着“${topic}”再追问一句`,
'先表明你的判断,再看对方反应',
'换个更轻一点的语气继续聊下去',
'你刚才那句是什么意思',
`这事和${topic}有关吗`,
'你愿意再说清楚点吗',
];
}

View File

@@ -1,469 +1 @@
import type {
CharacterChatReplyRequest,
CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
NpcChatTurnRequest,
NpcRecruitDialogueRequest,
} from '../../../../packages/shared/src/contracts/story.js';
type JsonRecord = Record<string, unknown>;
export const CHARACTER_PANEL_CHAT_SYSTEM_PROMPT = `你是像素动作 RPG 里的同行角色。
只回复这名角色此刻会对玩家说的话。
不要输出角色名、引号、旁白、动作提示、Markdown、JSON 或解释。
保持人设,结合最近剧情和关系变化,回复简洁自然。`;
export const CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT = `生成恰好 3 条玩家回复建议。
只输出纯文本,共 3 行,每行一条。
不要加编号、项目符号、Markdown 或额外说明。
三条建议语气要有区分:关心、追问、轻松或拉近关系。`;
export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `总结玩家与这名角色之间不断变化的关系。
只输出一段简洁文字。
包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`;
export const NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT = `你是角色扮演 RPG 的角色对话编剧。
你只能输出纯中文对话正文不能输出解释、代码、markdown、JSON 或额外说明。
硬性规则:
- 每一行都必须严格以“你:”或“角色名字:”开头。
- 第一行必须是“你:”开头。
- 总行数控制在 4 到 6 行。
- 玩家和对方至少各说 2 次。
- 这段内容只是聊天,不是做决定。
- 禁止在聊天里主动引导、建议、安排或预告交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。
- 禁止把情报直接写成对玩家的指令。
- 结束时要让玩家感觉到气氛、情报或关系发生了变化,但变化仍停留在聊天层面。`;
export const NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT = `你是角色扮演 RPG 的招募剧情对话编剧。
你只能输出纯中文对话正文不能输出解释、代码、markdown、JSON 或额外说明。
硬性规则:
- 每一行都必须严格以“你:”或“角色名字:”开头。
- 第一行必须是“你:”开头。
- 总行数控制在 4 到 6 行。
- 玩家和对方至少各说 2 次。
- 这段对话的目标是把“邀请对方入队”自然谈成。
- 不允许出现拒绝入队、继续观望、以后再说、条件未满足等结果。
- 不允许出现“我不能答应”“我还没想好”“再让我考虑”“暂时不行”“以后再说”这类拒绝或拖延表述。
- 最后一行必须由对方明确答应加入队伍。`;
export const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT = `你是角色扮演 RPG 里的当前 NPC。
你只输出这名 NPC 此刻会对玩家说的一轮回复。
只输出纯中文口语回复正文不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。
回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。`;
export const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT = `你要为玩家生成下一轮可直接点击的 3 条聊天续写候选。
只输出纯文本,共 3 行,每行 1 条。
不要加编号、项目符号、Markdown、JSON 或额外说明。
三条候选必须明显不同,分别体现继续追问、表达态度、轻微拉近关系这三种不同方向。`;
function asRecord(value: unknown): JsonRecord | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as JsonRecord)
: null;
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function readNumber(value: unknown, fallback = 0) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function readStringArray(value: unknown) {
return Array.isArray(value)
? value
.map((item) => readString(item))
.filter((item): item is string => Boolean(item))
: [];
}
function describeWorld(worldType: string) {
switch (worldType) {
case 'WUXIA':
return '边城模板';
case 'XIANXIA':
return '灵潮模板';
case 'CUSTOM':
return '自定义世界';
default:
return worldType || '未知世界';
}
}
function describeStats(label: string, record: JsonRecord | null) {
const hp = readNumber(record?.hp);
const maxHp = Math.max(1, readNumber(record?.maxHp, hp));
const mana = readNumber(record?.mana);
const maxMana = Math.max(1, readNumber(record?.maxMana, mana));
return `${label}生命 ${hp}/${maxHp},灵力 ${mana}/${maxMana}`;
}
function describeCharacter(label: string, value: unknown) {
const record = asRecord(value);
const name = readString(record?.name) ?? '未知角色';
const title = readString(record?.title) ?? '未知称号';
const description = readString(record?.description) ?? '暂无额外描述';
const personality = readString(record?.personality) ?? '性格信息未显式提供';
return [
`${label}姓名:${name}`,
`${label}称号:${title}`,
`${label}描述:${description}`,
`${label}性格:${personality}`,
].join('\n');
}
function describeStoryHistory(history: unknown) {
if (!Array.isArray(history) || history.length === 0) {
return '近期剧情:暂无。';
}
const lines = history
.slice(-4)
.map((item) => readString(asRecord(item)?.text))
.filter((item): item is string => Boolean(item));
return lines.length > 0
? ['近期剧情:', ...lines.map((line) => `- ${line}`)].join('\n')
: '近期剧情:暂无。';
}
function describeConversationHistory(history: unknown) {
if (!Array.isArray(history) || history.length === 0) {
return '聊天记录:暂无。';
}
const lines = history
.slice(-12)
.map((item) => {
const record = asRecord(item);
const speaker = readString(record?.speaker) === 'player' ? '玩家' : '角色';
const text = readString(record?.text);
return text ? `- ${speaker}${text}` : null;
})
.filter((item): item is string => Boolean(item));
return lines.length > 0
? ['聊天记录:', ...lines].join('\n')
: '聊天记录:暂无。';
}
function describeNpcConversationHistory(history: unknown, npcName: string) {
if (!Array.isArray(history) || history.length === 0) {
return '当前聊天记录:暂无。';
}
const lines = history
.slice(-10)
.map((item) => {
const record = asRecord(item);
const speaker = readString(record?.speaker);
const speakerName = readString(record?.speakerName);
const text = readString(record?.text);
if (!text) return null;
if (speaker === 'player') {
return `- 玩家:${text}`;
}
if (speaker === 'npc') {
return `- ${speakerName ?? npcName}${text}`;
}
if (speaker === 'system') {
return `- 系统提示:${text}`;
}
return `- ${speakerName ?? '同伴'}${text}`;
})
.filter((item): item is string => Boolean(item));
return lines.length > 0
? ['当前聊天记录:', ...lines].join('\n')
: '当前聊天记录:暂无。';
}
function describeSceneContext(context: unknown) {
const record = asRecord(context);
const sceneName = readString(record?.sceneName) ?? '当前区域';
const sceneDescription =
readString(record?.sceneDescription) ?? '周围气氛仍未完全安定。';
const inBattle = record?.inBattle === true ? '战斗中' : '非战斗';
const customWorldProfile = asRecord(record?.customWorldProfile);
const customWorldName = readString(customWorldProfile?.name);
const customWorldSummary = readString(customWorldProfile?.summary);
return [
`世界补充:${customWorldName ?? '无'}`,
customWorldSummary ? `世界摘要:${customWorldSummary}` : null,
`场景:${sceneName}`,
`场景描述:${sceneDescription}`,
`当前状态:${inBattle}`,
describeStats('玩家', record),
]
.filter(Boolean)
.join('\n');
}
function describeTargetStatus(status: unknown) {
const record = asRecord(status);
const roleLabel = readString(record?.roleLabel) ?? '同行角色';
const affinity = record?.affinity;
return [
`对方身份:${roleLabel}`,
describeStats('对方', record),
typeof affinity === 'number' ? `当前好感:${affinity}` : null,
]
.filter(Boolean)
.join('\n');
}
function describeEncounter(encounter: unknown) {
const record = asRecord(encounter);
const npcName = readString(record?.npcName) ?? '眼前角色';
const contextText =
readString(record?.context) ??
readString(record?.npcDescription) ??
'你们正在当前遭遇里继续对话。';
return {
npcName,
block: [`当前对象:${npcName}`, `对象背景:${contextText}`].join('\n'),
};
}
function describeMonsters(monsters: unknown) {
if (!Array.isArray(monsters) || monsters.length === 0) {
return '当前敌对目标:无。';
}
const lines = monsters
.slice(0, 4)
.map((item) => {
const record = asRecord(item);
const name =
readString(record?.name) ??
readString(record?.npcName) ??
readString(record?.id);
const hp = readNumber(record?.hp);
const maxHp = Math.max(1, readNumber(record?.maxHp, hp));
return name ? `- ${name}(生命 ${hp}/${maxHp}` : null;
})
.filter((item): item is string => Boolean(item));
return lines.length > 0
? ['当前敌对目标:', ...lines].join('\n')
: '当前敌对目标:无。';
}
function describeTargetCharacterName(payload: {
targetCharacter?: unknown;
encounter?: unknown;
}) {
return (
readString(asRecord(payload.targetCharacter)?.name) ??
readString(asRecord(payload.encounter)?.npcName) ??
'对方'
);
}
export function buildCharacterPanelChatPrompt(
payload: CharacterChatReplyRequest,
) {
const targetName = describeTargetCharacterName(payload);
return [
`世界:${describeWorld(payload.worldType)}`,
describeSceneContext(payload.context),
describeCharacter('玩家 / ', payload.playerCharacter),
describeCharacter('对方 / ', payload.targetCharacter),
describeTargetStatus(payload.targetStatus),
describeStoryHistory(payload.storyHistory),
payload.conversationSummary
? `之前聊天摘要:${payload.conversationSummary}`
: '之前聊天摘要:暂无。',
describeConversationHistory(payload.conversationHistory),
`玩家刚刚对 ${targetName} 说:${payload.playerMessage}`,
`现在请以 ${targetName} 的身份,直接回复玩家。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildCharacterPanelChatSuggestionPrompt(
payload: CharacterChatSuggestionsRequest,
) {
const targetName = describeTargetCharacterName(payload);
const latestCharacterReply = Array.isArray(payload.conversationHistory)
? [...payload.conversationHistory]
.reverse()
.map((item) => asRecord(item))
.find((record) => readString(record?.speaker) === 'character')
: null;
const latestReplyText = readString(latestCharacterReply?.text);
return [
`世界:${describeWorld(payload.worldType)}`,
describeSceneContext(payload.context),
describeCharacter('玩家 / ', payload.playerCharacter),
describeCharacter('对方 / ', payload.targetCharacter),
describeTargetStatus(payload.targetStatus),
describeStoryHistory(payload.storyHistory),
payload.conversationSummary
? `之前聊天摘要:${payload.conversationSummary}`
: '之前聊天摘要:暂无。',
describeConversationHistory(payload.conversationHistory),
latestReplyText
? `角色刚刚的回复:${latestReplyText}`
: `玩家正准备与 ${targetName} 开始一段新的私聊。`,
`请围绕当前气氛,为玩家生成 3 条可以直接发送给 ${targetName} 的简短回复候选。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildCharacterPanelChatSummaryPrompt(
payload: CharacterChatSummaryRequest,
) {
const targetName = describeTargetCharacterName(payload);
return [
`世界:${describeWorld(payload.worldType)}`,
describeSceneContext(payload.context),
describeCharacter('玩家 / ', payload.playerCharacter),
describeCharacter('对方 / ', payload.targetCharacter),
describeTargetStatus(payload.targetStatus),
describeStoryHistory(payload.storyHistory),
payload.previousSummary
? `旧摘要:${payload.previousSummary}`
: '旧摘要:暂无。',
describeConversationHistory(payload.conversationHistory),
`请把玩家与 ${targetName} 的旧摘要和最新聊天整理成一段更新后的关系摘要。`,
]
.filter(Boolean)
.join('\n\n');
}
function buildNpcDialoguePromptBase(
payload: NpcChatDialogueRequest | NpcChatTurnRequest | NpcRecruitDialogueRequest,
) {
const encounter = describeEncounter(payload.encounter);
const character =
(payload as NpcChatTurnRequest).character ??
(payload as NpcChatTurnRequest).player;
if (!(payload as NpcChatTurnRequest).character && character) {
(payload as NpcChatTurnRequest).character = character;
}
return [
`世界:${describeWorld(payload.worldType)}`,
describeSceneContext(payload.context),
describeCharacter('玩家 / ', payload.character),
encounter.block,
describeMonsters(payload.monsters),
describeStoryHistory(payload.history),
]
.filter(Boolean)
.join('\n\n');
}
export function buildStrictNpcChatDialoguePrompt(
payload: NpcChatDialogueRequest,
) {
const encounter = describeEncounter(payload.encounter);
const context = asRecord(payload.context);
const openingCampBackground = readString(context?.openingCampBackground);
const openingCampDialogue = readString(context?.openingCampDialogue);
const allowedTopics = readStringArray(context?.encounterAllowedTopics);
const blockedTopics = readStringArray(context?.encounterBlockedTopics);
return [
buildNpcDialoguePromptBase(payload),
openingCampBackground ? `营地开场背景:${openingCampBackground}` : null,
openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null,
allowedTopics.length > 0
? `当前更适合谈的内容:${allowedTopics.join('、')}`
: null,
blockedTopics.length > 0
? `当前避免直接说破:${blockedTopics.join('、')}`
: null,
`当前聊天主题:${payload.topic}`,
payload.resultSummary
? `这段聊天希望带来的变化:${payload.resultSummary}`
: '这段聊天要让气氛、情报或关系出现一层新的变化。',
`请围绕“${payload.topic}”写一段刚刚发生的对话。必须只输出对白正文,每一行都必须以“你:”或“${encounter.npcName}:”开头。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildNpcRecruitDialoguePrompt(
payload: NpcRecruitDialogueRequest,
) {
const encounter = describeEncounter(payload.encounter);
return [
buildNpcDialoguePromptBase(payload),
`玩家邀请:${payload.invitationText}`,
payload.recruitSummary
? `招募补充条件:${payload.recruitSummary}`
: '这轮对话已经具备自然邀请对方入队的条件。',
'这是一段“邀请对方入队”的对话。请让几轮交流逐步导向成功加入队伍,不要写出拒绝、观望或延期答复。',
`最后一行必须由 ${encounter.npcName} 明确答应加入队伍。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildNpcChatTurnReplyPrompt(
payload: NpcChatTurnRequest,
) {
const encounter = describeEncounter(payload.encounter);
const npcState = asRecord(payload.npcState);
const conversationHistory =
Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0
? payload.conversationHistory
: payload.dialogue ?? payload.conversationHistory ?? [];
const affinity = readNumber(npcState?.affinity, 0);
const chattedCount = readNumber(npcState?.chattedCount, 0);
return [
buildNpcDialoguePromptBase(payload),
describeNpcConversationHistory(conversationHistory, encounter.npcName),
`当前关系值:${affinity}`,
`已聊天轮次:${chattedCount}`,
`玩家刚刚说:${payload.playerMessage}`,
`现在请只写 ${encounter.npcName} 这一轮会回复玩家的话。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildNpcChatTurnSuggestionPrompt(
payload: NpcChatTurnRequest,
npcReply: string,
) {
const encounter = describeEncounter(payload.encounter);
const conversationHistory =
Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0
? payload.conversationHistory
: payload.dialogue ?? payload.conversationHistory ?? [];
return [
buildNpcDialoguePromptBase(payload),
describeNpcConversationHistory(conversationHistory, encounter.npcName),
`玩家刚刚说:${payload.playerMessage}`,
`NPC 刚刚回复:${npcReply}`,
`请围绕刚刚这轮对话,为玩家生成 3 条可以继续和 ${encounter.npcName} 聊下去的中文短句候选。`,
]
.filter(Boolean)
.join('\n\n');
}
export * from '../../prompts/chatPromptBuilders.js';

View File

@@ -22,17 +22,15 @@ import type {
CustomWorldCreatorIntent,
CustomWorldProfile,
} from '../../../../src/types.js';
import {
buildCustomWorldProfilePrompt,
buildCustomWorldProfileRepairPrompt,
CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT,
CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT,
} from '../../prompts/customWorldOrchestratorPrompts.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js';
type GeneratedProfile = Record<string, unknown>;
const CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的自定义世界 JSON 生成器。
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
你会收到一段本应为单个 JSON 对象的文本。
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
不要输出 Markdown、代码块、解释、注释或额外文字。
尽量保留原始语义,只修复格式问题;必要时可以补齐缺失的引号、逗号、括号、数组闭合或缺失字段。`;
const CUSTOM_WORLD_REQUEST_TIMEOUT_MS = 180000;
const FAST_CUSTOM_WORLD_PLAYABLE_COUNT = 3;
@@ -278,59 +276,6 @@ function createCustomWorldGenerationReporter(
};
}
function buildCustomWorldProfilePrompt(params: {
settingText: string;
generationSeedText: string;
creatorIntent: CustomWorldCreatorIntent | null;
generationMode: CustomWorldGenerationMode;
}) {
const targets = getCustomWorldGenerationTargets(params.generationMode);
const creatorIntentText =
params.creatorIntent && hasMeaningfulCustomWorldCreatorIntent(params.creatorIntent)
? buildCustomWorldCreatorIntentGenerationText(params.creatorIntent)
: '';
return [
'请根据创作者输入,生成一个可直接进入游戏的自定义世界档案 JSON。',
'必须严格输出单个 JSON 对象,不要 Markdown不要解释。',
'',
`生成模式:${params.generationMode}`,
`可扮演角色数量:${targets.playableCount}`,
`场景角色数量:${targets.storyCount}`,
`关键场景数量:${targets.landmarkCount}`,
'',
'创作者输入:',
params.generationSeedText,
creatorIntentText ? `\n结构化创作锚点\n${creatorIntentText}` : '',
'',
'输出 JSON 字段要求:',
'- name, subtitle, summary, tone, playerGoal, templateWorldType',
'- majorFactions: string[]coreConflicts: string[]',
'- camp: { name, description, dangerLevel }',
'- playableNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags',
'- storyNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags',
'- landmarks: 每项包含 name,description,dangerLevel,sceneNpcNames,connections',
'- connections 每项包含 targetLandmarkName, relativePosition, summarytargetLandmarkName 必须指向本次输出的其他场景名',
'',
'约束:',
'- 所有世界观、角色、场景必须贴合创作者输入,不要套用通用武侠模板。',
'- 角色名字、势力名、场景名必须互相区分,避免重复。',
'- sceneNpcNames 只能引用 storyNpcs 中已经输出的 name。',
'- templateWorldType 只能是 WUXIA 或 XIANXIA。',
'- dangerLevel 使用 low、medium、high、extreme 之一。',
'- relativePosition 使用 forward、back、left、right、up、down、inside、outside 之一。',
'- 不要预生成物品档案items 如需输出,必须为空数组。',
].filter(Boolean).join('\n');
}
function buildCustomWorldProfileRepairPrompt(responseText: string) {
return [
'请修复下面的自定义世界 JSON。',
'只输出能被 JSON.parse 直接解析的单个 JSON 对象,不要解释。',
responseText,
].join('\n\n');
}
async function parseCustomWorldJsonStage(params: {
llmClient: UpstreamLlmClient;
responseText: string;
@@ -424,16 +369,21 @@ export async function generateCustomWorldProfileFromOrchestrator(
creatorIntent,
generationMode,
} = resolveCustomWorldGenerationInput(input);
const targets = getCustomWorldGenerationTargets(generationMode);
const creatorIntentText =
creatorIntent && hasMeaningfulCustomWorldCreatorIntent(creatorIntent)
? buildCustomWorldCreatorIntentGenerationText(creatorIntent)
: '';
const reporter = createCustomWorldGenerationReporter(options.onProgress);
try {
throwIfCustomWorldGenerationAborted(options.signal);
reporter.begin('prepare', '正在整理创作者输入与结构化锚点。');
const userPrompt = buildCustomWorldProfilePrompt({
settingText,
generationSeedText,
creatorIntent,
generationMode,
creatorIntentText,
targets,
});
reporter.complete('prepare', '设定上下文已整理,开始请求大模型推理。');

View File

@@ -1,6 +1,10 @@
import { hasMixedNarrativeLanguage } from '../../../../packages/shared/src/llm/narrativeLanguage.js';
import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js';
import {
buildStoryLanguageRepairPrompt,
STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT,
} from '../../prompts/storyOrchestratorPrompts.js';
import { buildUserPrompt, SYSTEM_PROMPT } from './storyPromptBuilders.js';
type JsonRecord = Record<string, unknown>;
@@ -64,12 +68,6 @@ type RawOptionItem = {
actionText?: string;
};
const STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT = `你是 RPG 中文叙事文本修复器。
你会收到一个已经解析过的剧情 JSON 对象。
你的唯一任务是把 storyText 和 options[].actionText 中的英文句子、中英混杂句式、英文解释改写成自然中文。
必须保持 JSON 结构、encounter、options 数量、options 顺序以及每个 functionId 完全不变。
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释、注释或额外文字。`;
const DEFAULT_VISUALS = {
playerAnimation: 'idle' as const,
playerMoveMeters: 0,
@@ -83,6 +81,8 @@ const STATIC_FALLBACK_OPTION_MAP: Record<
string,
Partial<PromptStoryOption> & { actionText: string }
> = {
battle_attack_basic: { actionText: '普通攻击' },
battle_use_skill: { actionText: '释放技能' },
battle_all_in_crush: { actionText: '正面强压敌人' },
battle_escape_breakout: { actionText: '先脱离眼前追杀' },
battle_feint_step: { actionText: '借假动作切进身位' },
@@ -334,11 +334,9 @@ function resolveOptionsFromOptionCatalog(
function getFallbackFunctionIds(context: PromptContext, encounter?: SceneEncounterResult) {
if (context.inBattle === true) {
return [
'battle_probe_pressure',
'battle_guard_break',
'battle_attack_basic',
'battle_recover_breath',
'battle_feint_step',
'battle_finisher_window',
'battle_use_skill',
'battle_escape_breakout',
];
}
@@ -381,25 +379,6 @@ function getFallbackOptions(
);
}
function buildStoryLanguageRepairPrompt(response: AIResponse) {
return [
'请把下面 JSON 中的 storyText 与 options[].actionText 修复为自然中文。',
'只改写叙事和选项文案,不要改变 encounter、options 数量、顺序或 functionId。',
JSON.stringify(
{
storyText: response.storyText,
encounter: response.encounter ?? null,
options: response.options.map((option) => ({
functionId: option.functionId,
actionText: option.actionText,
})),
},
null,
2,
),
].join('\n\n');
}
function needsStoryLanguageRepair(response: AIResponse) {
return hasMixedNarrativeLanguage(response.storyText);
}

View File

@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildUserPrompt } from './storyPromptBuilders.js';
test('buildUserPrompt adds post-chat reevaluation guidance for npc option catalogs', () => {
const prompt = buildUserPrompt({
worldType: 'WUXIA',
character: {
name: '沈行',
title: '试剑客',
description: '测试角色',
personality: '谨慎',
},
monsters: [],
history: [
{ text: '你:刚才那句话是什么意思?' },
{ text: '山道客:你最好别继续深究。' },
],
context: {
sceneName: '山道',
sceneDescription: '风声贴着碎石一路往前卷。',
encounterName: '山道客',
playerHp: 100,
playerMaxHp: 100,
playerMana: 30,
playerMaxMana: 30,
inBattle: false,
pendingSceneEncounter: false,
lastFunctionId: 'npc_chat',
},
choice: '结束与山道客的这轮交谈,重新观察当前局势',
requestOptions: {
optionCatalog: [
{
functionId: 'npc_chat',
actionText: '继续交谈',
},
{
functionId: 'npc_help',
actionText: '请求援手',
},
{
functionId: 'npc_trade',
actionText: '看看能交换什么',
},
],
},
});
assert.match(prompt, / NPC /u);
assert.match(prompt, /退/u);
assert.match(prompt, / function /u);
});

View File

@@ -1,163 +1 @@
type JsonRecord = Record<string, unknown>;
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function readNumber(value: unknown, fallback = 0) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function describeWorld(worldType: string) {
switch (worldType) {
case 'WUXIA':
return '边城模板';
case 'XIANXIA':
return '灵潮模板';
case 'CUSTOM':
return '自定义世界';
default:
return worldType || '未知世界';
}
}
function describeCharacter(character: JsonRecord) {
return [
`主角:${readString(character.name) ?? '未知角色'}`,
`称号:${readString(character.title) ?? '未知称号'}`,
`描述:${readString(character.description) ?? '暂无'}`,
`性格:${readString(character.personality) ?? '未显式提供'}`,
].join('\n');
}
function describeMonsters(monsters: JsonRecord[]) {
if (monsters.length <= 0) {
return '当前敌对目标:无。';
}
return [
'当前敌对目标:',
...monsters.slice(0, 4).map((monster) => {
const name = readString(monster.name) ?? readString(monster.id) ?? '未知目标';
const hp = readNumber(monster.hp);
const maxHp = Math.max(1, readNumber(monster.maxHp, hp));
return `- ${name}(生命 ${hp}/${maxHp}`;
}),
].join('\n');
}
function describeStoryHistory(history: JsonRecord[]) {
if (history.length <= 0) {
return '近期剧情:暂无。';
}
return [
'近期剧情:',
...history.slice(-4).map((moment) => `- ${readString(moment.text) ?? '(空白)'}`),
].join('\n');
}
function describeRequestOptions(options: {
availableOptions?: Array<Record<string, unknown>>;
optionCatalog?: Array<Record<string, unknown>>;
}) {
const available = options.availableOptions ?? [];
const catalog = options.optionCatalog ?? [];
if (available.length > 0) {
return [
'固定可选项列表:',
...available.map((option, index) => {
const functionId = readString(option.functionId) ?? 'unknown';
const actionText =
readString(option.actionText) ??
readString(option.text) ??
'未提供文案';
return `- 第 ${index + 1} 项 / ${functionId}${actionText}`;
}),
'必须保持数量不变functionId 不变,可以重写 actionText。'.trim(),
].join('\n');
}
if (catalog.length > 0) {
return [
'当前局面可调用的交互选项目录:',
...catalog.map((option, index) => {
const functionId = readString(option.functionId) ?? 'unknown';
const actionText =
readString(option.actionText) ??
readString(option.text) ??
'未提供文案';
return `- 第 ${index + 1} 项 / ${functionId}${actionText}`;
}),
'functionId 只能从上面目录里选择。'.trim(),
].join('\n');
}
return '当前没有固定目录,请根据局势生成合理选项。';
}
export const SYSTEM_PROMPT = `你是角色扮演 RPG 的剧情推进者,只能返回 JSON 对象不能输出解释、markdown 或代码块。
输出格式必须严格符合:
{
"storyText": "剧情文本",
"encounter": null,
"options": [
{
"functionId": "预定义功能ID",
"actionText": "选项显示文本"
}
]
}
严格规则:
- 所有文本必须是中文。
- 如果提示语给出了固定可选项或可选目录,必须遵守给定的 functionId 范围。
- storyText 必须直接承接当前场景、最近剧情和玩家刚刚的动作。
- options 只允许输出 functionId 和 actionText。
- 如果当前不是“继续推进后下一刻会遇到什么”的场景encounter 必须保持为 null。`;
export function buildUserPrompt(params: {
worldType: string;
character: JsonRecord;
monsters: JsonRecord[];
history: JsonRecord[];
context: JsonRecord;
choice?: string;
requestOptions?: {
availableOptions?: Array<Record<string, unknown>>;
optionCatalog?: Array<Record<string, unknown>>;
};
}) {
const sceneName = readString(params.context.sceneName) ?? '当前区域';
const sceneDescription = readString(params.context.sceneDescription) ?? '暂无场景附加描述。';
const encounterName = readString(params.context.encounterName);
const playerHp = readNumber(params.context.playerHp);
const playerMaxHp = Math.max(1, readNumber(params.context.playerMaxHp, playerHp));
const playerMana = readNumber(params.context.playerMana);
const playerMaxMana = Math.max(1, readNumber(params.context.playerMaxMana, playerMana));
const inBattle = params.context.inBattle === true ? '战斗中' : '非战斗';
const pendingSceneEncounter =
params.context.pendingSceneEncounter === true ? '是' : '否';
return [
`世界:${describeWorld(params.worldType)}`,
`场景:${sceneName}`,
`场景描述:${sceneDescription}`,
encounterName ? `当前面前对象:${encounterName}` : null,
`当前状态:${inBattle}`,
`玩家生命:${playerHp}/${playerMaxHp}`,
`玩家灵力:${playerMana}/${playerMaxMana}`,
`是否需要判断下一刻遭遇:${pendingSceneEncounter}`,
describeCharacter(params.character),
describeMonsters(params.monsters),
describeStoryHistory(params.history),
params.choice ? `玩家刚刚选择:${params.choice}` : '玩家刚进入当前局面。',
describeRequestOptions(params.requestOptions ?? {}),
params.context.pendingSceneEncounter === true
? '如果这一轮明确是在判断继续推进后下一刻会遇到什么,可以填写 encounter否则 encounter 必须为 null。'
: '当前这一步不是新的遭遇生成流程encounter 必须为 null。',
]
.filter(Boolean)
.join('\n\n');
}
export * from '../../prompts/storyPromptBuilders.js';

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,17 @@
import type {
RuntimeBattlePresentation,
RuntimeStoryChoicePayload,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
import { conflict } from '../../errors.js';
import {
appendBuildBuffs,
resolvePlayerOutgoingDamageResult,
} from '../runtime/runtimeBuildModule.js';
import {
getEncounterNpcState,
getPlayerCharacter,
getPlayerSkillCooldowns,
setEncounterNpcState,
type RuntimeSession,
} from '../story/runtimeSession.js';
@@ -16,6 +23,15 @@ type CombatActionConfig = {
counterMultiplier: number;
heal?: number;
manaRestore?: number;
cooldownBonus?: number;
selectedSkillId?: string | null;
appliedCooldownTurns?: number;
buildBuffs?: Array<{
id: string;
name: string;
tags: string[];
durationTurns: number;
}>;
};
export type CombatResolution = {
@@ -26,46 +42,21 @@ export type CombatResolution = {
storyText?: string;
};
const COMBAT_ACTIONS: Record<string, CombatActionConfig> = {
battle_all_in_crush: {
actionText: '正面强压',
manaCost: 14,
baseDamage: 22,
counterMultiplier: 1.25,
},
battle_feint_step: {
actionText: '虚晃切步',
manaCost: 8,
baseDamage: 16,
counterMultiplier: 0.7,
},
battle_finisher_window: {
actionText: '抓破绽终结',
manaCost: 10,
baseDamage: 18,
counterMultiplier: 0.9,
},
battle_guard_break: {
actionText: '破架重击',
manaCost: 9,
baseDamage: 17,
counterMultiplier: 0.95,
},
battle_probe_pressure: {
actionText: '稳步试探',
manaCost: 5,
baseDamage: 12,
counterMultiplier: 0.8,
},
battle_recover_breath: {
actionText: '边守边调息',
manaCost: 0,
baseDamage: 0,
counterMultiplier: 0.55,
heal: 12,
manaRestore: 9,
},
};
const LEGACY_ATTACK_FUNCTION_IDS = new Set<string>([
'battle_all_in_crush',
'battle_guard_break',
'battle_probe_pressure',
'battle_feint_step',
'battle_finisher_window',
]);
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function getAliveTarget(session: RuntimeSession) {
return session.sceneHostileNpcs.find((npc) => npc.hp > 0) ?? null;
@@ -124,19 +115,120 @@ function finishBattle(
}
}
function buildBasicAttackBaseDamage(session: RuntimeSession) {
const character = getPlayerCharacter(session);
if (!character) {
return 10;
}
return Math.max(
8,
Math.round(
character.attributes.strength * 0.85 +
character.attributes.agility * 0.45,
),
);
}
function tickCooldownMap(
cooldowns: Record<string, number>,
turns: number,
) {
let nextCooldowns = cooldowns;
for (let index = 0; index < Math.max(0, Math.floor(turns)); index += 1) {
nextCooldowns = Object.fromEntries(
Object.entries(nextCooldowns).map(([skillId, value]) => [
skillId,
Math.max(0, Math.floor(value) - 1),
]),
);
}
return nextCooldowns;
}
function resolveCombatActionConfig(params: {
session: RuntimeSession;
functionId: string;
payload?: RuntimeStoryChoicePayload;
}) {
const { session, functionId, payload } = params;
if (functionId === 'battle_recover_breath') {
return {
actionText: '恢复',
manaCost: 0,
baseDamage: 0,
counterMultiplier: 0.55,
heal: 12,
manaRestore: 9,
cooldownBonus: 1,
selectedSkillId: null,
} satisfies CombatActionConfig;
}
if (functionId === 'battle_attack_basic' || LEGACY_ATTACK_FUNCTION_IDS.has(functionId)) {
return {
actionText: '普通攻击',
manaCost: 0,
baseDamage: buildBasicAttackBaseDamage(session),
counterMultiplier: 1,
selectedSkillId: null,
} satisfies CombatActionConfig;
}
if (functionId === 'battle_use_skill') {
const character = getPlayerCharacter(session);
if (!character) {
throw conflict('缺少玩家角色,无法结算技能动作');
}
const skillId = readString(isObject(payload) ? payload.skillId : '');
if (!skillId) {
throw conflict('battle_use_skill 缺少 skillId');
}
const skill = character.skills.find((candidate) => candidate.id === skillId);
if (!skill) {
throw conflict(`未找到技能:${skillId}`);
}
const cooldowns = getPlayerSkillCooldowns(session);
if ((cooldowns[skill.id] ?? 0) > 0) {
throw conflict(`${skill.name} 仍在冷却中`);
}
return {
actionText: skill.name,
manaCost: skill.manaCost,
baseDamage: skill.damage,
counterMultiplier: 0.95,
selectedSkillId: skill.id,
appliedCooldownTurns: skill.cooldownTurns,
buildBuffs: skill.buildBuffs ?? [],
} satisfies CombatActionConfig;
}
throw conflict(`暂不支持的战斗动作:${functionId}`);
}
export function resolveCombatAction(
session: RuntimeSession,
functionId: string,
params: {
functionId: string;
payload?: RuntimeStoryChoicePayload;
},
): CombatResolution {
const target = getAliveTarget(session);
if (!session.inBattle || !target) {
throw conflict('当前不在可结算战斗态,不能执行该战斗动作');
}
if (functionId === 'battle_escape_breakout') {
if (params.functionId === 'battle_escape_breakout') {
finishBattle(session, 'escaped');
return {
actionText: '强行脱离战斗',
actionText: '逃跑',
resultText: `你抓住空当摆脱了${target.name}的压制,先把这轮正面冲突拉开了。`,
battle: {
targetId: target.id,
@@ -146,7 +238,7 @@ export function resolveCombatAction(
patches: [
{
type: 'battle_resolved',
functionId,
functionId: params.functionId,
targetId: target.id,
outcome: 'escaped',
},
@@ -165,27 +257,66 @@ export function resolveCombatAction(
};
}
const action = COMBAT_ACTIONS[functionId];
if (!action) {
throw conflict(`暂不支持的战斗动作:${functionId}`);
}
const action = resolveCombatActionConfig({
session,
functionId: params.functionId,
payload: params.payload,
});
if (action.manaCost > session.playerMana) {
throw conflict('当前灵力不足,无法执行这个战斗动作');
}
const character = getPlayerCharacter(session);
if (!character) {
throw conflict('缺少玩家角色,无法结算战斗动作');
}
const isSpar = session.currentNpcBattleMode === 'spar';
const targetHpRatio = target.hp / Math.max(target.maxHp, 1);
const damageBonus =
functionId === 'battle_finisher_window' && targetHpRatio <= 0.4 ? 8 : 0;
const damageDealt = isSpar ? 1 : action.baseDamage + damageBonus;
const damageResult =
action.baseDamage > 0
? resolvePlayerOutgoingDamageResult(
session.rawGameState as Parameters<typeof resolvePlayerOutgoingDamageResult>[0],
character,
action.baseDamage,
1,
`${params.functionId}:${action.selectedSkillId ?? 'default'}:${target.id}:${session.runtimeVersion}`,
)
: null;
const damageDealt = isSpar
? action.baseDamage > 0
? 1
: 0
: damageResult?.damage ?? 0;
session.playerMana -= action.manaCost;
session.playerHp += action.heal ?? 0;
session.playerMana += action.manaRestore ?? 0;
let nextCooldowns = tickCooldownMap(getPlayerSkillCooldowns(session), 1);
if ((action.cooldownBonus ?? 0) > 0) {
nextCooldowns = tickCooldownMap(nextCooldowns, action.cooldownBonus ?? 0);
}
if (action.selectedSkillId && (action.appliedCooldownTurns ?? 0) > 0) {
nextCooldowns = {
...nextCooldowns,
[action.selectedSkillId]: action.appliedCooldownTurns,
};
}
session.rawGameState.playerSkillCooldowns = nextCooldowns;
if (action.buildBuffs?.length) {
session.rawGameState.activeBuildBuffs = appendBuildBuffs(
(session.rawGameState.activeBuildBuffs as Parameters<typeof appendBuildBuffs>[0]) ??
[],
action.buildBuffs as Parameters<typeof appendBuildBuffs>[1],
);
}
clampPlayerVitals(session);
target.hp = Math.max(isSpar ? 1 : 0, target.hp - damageDealt);
if (damageDealt > 0) {
target.hp = Math.max(isSpar ? 1 : 0, target.hp - damageDealt);
}
const patches: RuntimeStoryPatch[] = [];
let resultText = '';
@@ -204,12 +335,15 @@ export function resolveCombatAction(
} else {
finishBattle(session, 'victory');
outcome = 'victory';
resultText = `你彻底压垮了${target.name}的节奏,这场战斗已经收口`;
resultText = `这一手彻底压垮了${target.name},眼前战斗已经正式结束`;
}
} else {
const baseCounter = isSpar
? 1
: Math.max(4, Math.round(target.maxHp * 0.16 * action.counterMultiplier));
: Math.max(
4,
Math.round(target.maxHp * 0.14 * action.counterMultiplier),
);
damageTaken = baseCounter;
session.playerHp = Math.max(isSpar ? 1 : 0, session.playerHp - damageTaken);
@@ -220,7 +354,7 @@ export function resolveCombatAction(
patches.push(affinityPatch);
}
outcome = 'spar_complete';
resultText = `${target.name}也逼到了你的极限,这场切磋点到为止,双方都默认收手。`;
resultText = `${target.name}把你逼到了极限,这场切磋点到为止,双方都默认收手。`;
} else if (!isSpar && session.playerHp <= 0) {
session.playerHp = 0;
session.inBattle = false;
@@ -230,15 +364,19 @@ export function resolveCombatAction(
session.currentEncounter = null;
outcome = 'escaped';
resultText = `你在和${target.name}的交锋里被压到失去战斗能力,这轮正面冲突只能先断开。`;
} else if (params.functionId === 'battle_recover_breath') {
resultText = `你先把伤势和气息稳住了一轮,但${target.name}仍在持续逼近。`;
} else if (params.functionId === 'battle_use_skill') {
resultText = `${action.actionText}命中了${target.name},这一轮技能效果已经直接结算。`;
} else {
resultText = `${action.actionText}命中了${target.name}但对方仍然顶住并回敬了一轮压力`;
resultText = `${action.actionText}命中了${target.name}本次攻击已经完成结算`;
}
}
patches.push(
{
type: 'battle_resolved',
functionId,
functionId: params.functionId,
targetId: target.id,
damageDealt,
damageTaken,

View File

@@ -5,8 +5,14 @@ import {
QUEST_REWARD_THEMES,
QUEST_URGENCY_LEVELS,
} from '../../../../packages/shared/src/contracts/story.js';
import {
buildQuestIntentPrompt,
QUEST_INTENT_SYSTEM_PROMPT,
} from '../../prompts/questPrompts.js';
import { formatCurrency } from '../runtime/runtimeEconomyPrimitives.js';
export { buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT };
export type QuestNarrativeType = (typeof QUEST_NARRATIVE_TYPES)[number];
export type QuestObjectiveKind = (typeof QUEST_OBJECTIVE_KINDS)[number];
export type QuestUrgency = (typeof QUEST_URGENCY_LEVELS)[number];
@@ -669,169 +675,6 @@ function getSignalProgressIncrement(signal: QuestProgressSignal) {
return signal.kind === 'item_delivered' ? Math.max(1, signal.quantity) : 1;
}
function summarizeRecentStoryMoments(context: QuestGenerationContext) {
const moments = context.recentStoryMoments
.slice(-4)
.map((moment) => `- ${moment.text}`)
.join('\n');
return moments || '- 暂无近期剧情记录';
}
function summarizeCurrentQuests(context: QuestGenerationContext) {
const summary = context.currentQuestSummary
?.map(
(quest) =>
`- ${quest.title}${quest.status}),发布者 ${quest.issuerNpcId}`,
)
.join('\n');
return summary || '- 当前没有进行中的任务';
}
function summarizeCompanions(context: QuestGenerationContext) {
const active =
context.activeCompanions?.map((companion) => companion.characterId).join('、') ||
'无';
const roster =
context.rosterCompanions?.map((companion) => companion.characterId).join('、') ||
'无';
return `当前同行角色:${active}\n队伍名册${roster}`;
}
function summarizePlayerState(context: QuestGenerationContext) {
const playerName = context.playerCharacter?.name ?? '未知角色';
const playerTitle = context.playerCharacter?.title ?? '未知称号';
const hp = `${context.playerHp ?? 0}/${context.playerMaxHp ?? 0}`;
const mana = `${context.playerMana ?? 0}/${context.playerMaxMana ?? 0}`;
const inventory =
context.playerInventory?.slice(0, 8).map((item) => item.name).join('、') || '无';
return [
`玩家:${playerName}${playerTitle}`,
`生命:${hp}`,
`灵力:${mana}`,
`背包快照:${inventory}`,
].join('\n');
}
function summarizeScene(
scene: QuestSceneSnapshot | null,
context: QuestGenerationContext,
) {
const hostileNpcIds = context.currentSceneHostileNpcIds?.join('、') || '无';
const treasureHintCount = context.currentSceneTreasureHintCount ?? 0;
return [
`场景:${scene?.name ?? context.currentSceneName ?? '未知区域'}`,
`场景描述:${scene?.description ?? context.currentSceneDescription ?? '暂无'}`,
`敌对角色 ID${hostileNpcIds}`,
`宝藏线索数量:${treasureHintCount}`,
].join('\n');
}
function summarizeActiveThreads(context: QuestGenerationContext) {
return context.activeThreadIds?.length
? context.activeThreadIds.join('、')
: '暂无明确激活线程';
}
function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) {
const profile = context.issuerNarrativeProfile;
if (!profile) {
return '暂无额外叙事档案';
}
return [
`公开面:${profile.publicMask ?? '暂无'}`,
`表层线:${profile.visibleLine ?? '暂无'}`,
`当前压力:${profile.immediatePressure ?? '暂无'}`,
profile.reactionHooks?.length
? `反应钩子:${profile.reactionHooks.join('、')}`
: null,
]
.filter(Boolean)
.join('\n');
}
function describeWorld(worldType: QuestGenerationContext['worldType']) {
switch (worldType) {
case 'WUXIA':
return '边城模板';
case 'XIANXIA':
return '灵潮模板';
case 'CUSTOM':
return '自定义世界';
default:
return '未知世界';
}
}
export const QUEST_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的任务导演。
只返回 JSON不要输出 Markdown。
输出结构:
{
"intent": {
"title": "中文任务标题",
"description": "中文任务描述",
"summary": "中文短摘要",
"narrativeType": "bounty|escort|investigation|retrieval|relationship|trial",
"dramaticNeed": "string",
"issuerGoal": "string",
"playerHook": "string",
"worldReason": "string",
"recommendedObjectiveKinds": ["defeat_hostile_npc" | "inspect_treasure" | "spar_with_npc" | "talk_to_npc" | "reach_scene" | "deliver_item"],
"urgency": "low|medium|high",
"intimacy": "transactional|cooperative|trust_based",
"rewardTheme": "currency|resource|relationship|intel|rare_item",
"followupHooks": ["string"]
}
}
规则:
- 所有自然语言字段都必须使用中文。
- 任务必须扎根于当前场景、发布者和近期剧情。
- 不要编造奖励、ID、数量、状态或不受支持的规则变化。
- recommendedObjectiveKinds 只是语义建议,不是硬编码结果。
- 优先给出简洁、可玩的任务框架,不要堆砌华丽辞藻。
- title 必须是 4 到 10 个中文字符左右的短任务名,不要写成长句。
- description 解释任务为什么在当前剧情里成立,避免纯规则说明。
- summary 必须是清晰达成条件,优先使用“击败 / 调查 / 返回 / 前往 / 交付 / 切磋”等明确动词,不要写“处理一下”“看看情况”这类模糊表达。`;
export function buildQuestIntentPrompt(params: {
context: QuestGenerationContext;
scene: QuestSceneSnapshot | null;
opportunity: QuestOpportunity;
}) {
const { context, scene, opportunity } = params;
const customWorldSummary = context.customWorldProfile
? `${context.customWorldProfile.name ?? '自定义世界'}: ${
context.customWorldProfile.summary ?? '暂无摘要'
}`
: '无';
return [
`世界:${describeWorld(context.worldType)}`,
`自定义世界摘要:${customWorldSummary}`,
`发布角色:${context.issuerNpcName}${context.issuerNpcId}`,
`发布者身份:${context.issuerNpcContext || '暂无'}`,
`发布者好感:${context.issuerAffinity ?? 0}`,
`发布者信息揭示阶段:${context.issuerDisclosureStage ?? '未知'}`,
`发布者亲疏阶段:${context.issuerWarmthStage ?? '未知'}`,
`当前激活线程:${summarizeActiveThreads(context)}`,
`发布者叙事档案:\n${summarizeIssuerNarrativeProfile(context)}`,
`当前遭遇类型:${context.encounterKind ?? '无'}`,
summarizeScene(scene, context),
summarizePlayerState(context),
summarizeCompanions(context),
`当前任务机会:${opportunity.reason}`,
`当前任务列表:\n${summarizeCurrentQuests(context)}`,
`近期剧情片段:\n${summarizeRecentStoryMoments(context)}`,
'现在请基于这次具体局势,生成一个自然生长出来的任务意图。',
].join('\n\n');
}
export function buildQuestGenerationContextFromState(params: {
state: RuntimeStateLike;
encounter: RuntimeEncounterLike;

View File

@@ -2,6 +2,12 @@ import {
RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES,
RUNTIME_ITEM_TONE_VALUES,
} from '../../../../packages/shared/src/contracts/story.js';
import {
buildRuntimeItemIntentPromptText,
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
} from '../../prompts/runtimeItemPrompts.js';
export { RUNTIME_ITEM_INTENT_SYSTEM_PROMPT };
export type RuntimeItemFunctionalBias =
(typeof RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES)[number];
@@ -573,48 +579,16 @@ function describePlan(
].join('\n');
}
export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的运行时物品导演。
你只返回 JSON不要输出 Markdown、解释或代码块。
输出结构:
{
"intents": [
{
"shortNameSeed": "中文短种子",
"sourcePhrase": "中文来源短语",
"reasonToAppear": "中文出现理由",
"relationHooks": ["中文关系钩子"],
"desiredBuildTags": ["中文 build 标签"],
"desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"],
"tone": "grim|mysterious|martial|ritual|survival",
"visibleClue": "玩家第一眼能抓到的痕迹",
"witnessMark": "它见证过什么的使用痕",
"unfinishedBusiness": "背后仍未结清的问题",
"hiddenHook": "更深一层但别直接讲穿的钩子",
"reactionHooks": ["以后谁会对它起反应"],
"namingPattern": "命名范式建议"
}
]
}
规则:
- intents 数量必须与输入物品数量完全一致,顺序也必须一致。
- 所有自然语言字段都必须使用中文。
- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。
- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。
- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。
- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`;
export function buildRuntimeItemIntentPrompt(params: {
context: RuntimeItemGenerationContext;
plans: RuntimeItemPlan[];
}) {
return [
`生成渠道:${params.context.generationChannel}`,
`以下每个物品都需要给出一条可编译的运行时物品意图。`,
...params.plans.map((plan, index) => describePlan(params.context, plan, index)),
'请严格返回 JSON。',
].join('\n\n');
return buildRuntimeItemIntentPromptText({
generationChannel: params.context.generationChannel,
planBlocks: params.plans.map((plan, index) =>
describePlan(params.context, plan, index),
),
});
}
function buildBaseRuntimeContext(params: {

View File

@@ -1,11 +1,17 @@
import type {
RuntimeStoryEncounterViewModel,
RuntimeStoryChoicePayload,
RuntimeStoryOptionView,
RuntimeStoryViewModel,
Task5RuntimeOptionScope,
} from '../../../../packages/shared/src/contracts/story.js';
import { TASK6_RUNTIME_FUNCTION_IDS } from '../../../../packages/shared/src/contracts/story.js';
import type { SavedSnapshot } from '../../repositories/runtimeRepository.js';
import { resolvePlayerOutgoingDamageResult } from '../runtime/runtimeBuildModule.js';
import {
isInventoryItemUsable,
resolveInventoryItemUseEffect,
} from '../runtime/runtimeInventoryEffectsModule.js';
type JsonRecord = Record<string, unknown>;
type StoryHistoryRole = 'action' | 'result';
@@ -62,6 +68,58 @@ export type RuntimeCompanion = {
joinedAtAffinity: number;
};
type RuntimePlayerAttributes = {
strength: number;
agility: number;
intelligence: number;
spirit: number;
};
type RuntimePlayerSkill = {
id: string;
name: string;
damage: number;
manaCost: number;
cooldownTurns: number;
buildBuffs?: Array<{
id: string;
sourceType: 'skill' | 'item' | 'forge';
sourceId: string;
name: string;
tags: string[];
durationTurns: number;
maxStacks?: number;
}>;
};
type RuntimePlayerCharacter = {
attributes: RuntimePlayerAttributes;
skills: RuntimePlayerSkill[];
};
type RuntimeBattleItemUseProfile = {
hpRestore?: number;
manaRestore?: number;
cooldownReduction?: number;
buildBuffs?: Array<{
id: string;
sourceType: 'item';
sourceId: string;
name: string;
tags: string[];
durationTurns: number;
}>;
};
type RuntimeBattleInventoryItem = {
id: string;
name: string;
quantity: number;
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
tags: string[];
useProfile?: RuntimeBattleItemUseProfile;
};
export type RuntimeSession = {
sessionId: string;
runtimeVersion: number;
@@ -97,6 +155,8 @@ const STORY_FUNCTION_IDS = new Set<string>([
]);
const COMBAT_FUNCTION_IDS = new Set<string>([
'battle_attack_basic',
'battle_use_skill',
'battle_all_in_crush',
'battle_escape_breakout',
'battle_feint_step',
@@ -164,6 +224,16 @@ const FUNCTION_DEFINITIONS: Record<string, FunctionDefinition> = {
detailText: '收束当前遭遇并切往下一段场景流程。',
scope: 'story',
},
battle_attack_basic: {
actionText: '普通攻击',
detailText: '本回合执行一次不耗蓝的基础攻击。',
scope: 'combat',
},
battle_use_skill: {
actionText: '释放技能',
detailText: '直接执行一个具体技能,不再包装成抽象战术动作。',
scope: 'combat',
},
battle_all_in_crush: {
actionText: '正面强压',
detailText: '高消耗高压制,适合趁敌人还没稳住时硬顶上去。',
@@ -195,8 +265,13 @@ const FUNCTION_DEFINITIONS: Record<string, FunctionDefinition> = {
scope: 'combat',
},
battle_recover_breath: {
actionText: '边守边调息',
detailText: '优先回稳资源,但仍可能吃到轻量反击。',
actionText: '恢复',
detailText: '直接恢复资源,并推进本回合冷却。',
scope: 'combat',
},
inventory_use: {
actionText: '使用物品',
detailText: '战斗中优先执行一个可立即结算的消耗品。',
scope: 'combat',
},
npc_chat: {
@@ -430,6 +505,344 @@ function normalizeHostileNpcs(value: unknown) {
.filter((entry): entry is RuntimeHostileNpc => Boolean(entry));
}
function normalizePlayerSkill(value: unknown): RuntimePlayerSkill | null {
const rawSkill = isObject(value) ? value : null;
if (!rawSkill) {
return null;
}
const id = readString(rawSkill.id);
const name = readString(rawSkill.name, id);
if (!id || !name) {
return null;
}
return {
id,
name,
damage: Math.max(1, Math.round(readNumber(rawSkill.damage, 1))),
manaCost: Math.max(0, Math.round(readNumber(rawSkill.manaCost, 0))),
cooldownTurns: Math.max(
0,
Math.round(readNumber(rawSkill.cooldownTurns, 0)),
),
buildBuffs: readArray(rawSkill.buildBuffs)
.map((entry) => {
const rawBuff = isObject(entry) ? entry : null;
if (!rawBuff) {
return null;
}
const buffId = readString(rawBuff.id);
const sourceId = readString(rawBuff.sourceId);
const name = readString(rawBuff.name, buffId);
if (!buffId || !sourceId || !name) {
return null;
}
const sourceType = readString(rawBuff.sourceType, 'skill');
return {
id: buffId,
sourceType:
sourceType === 'item' || sourceType === 'forge'
? sourceType
: 'skill',
sourceId,
name,
tags: readArray(rawBuff.tags).filter(
(tag): tag is string =>
typeof tag === 'string' && tag.trim().length > 0,
),
durationTurns: Math.max(
1,
Math.round(readNumber(rawBuff.durationTurns, 1)),
),
maxStacks:
typeof rawBuff.maxStacks === 'number' &&
Number.isFinite(rawBuff.maxStacks)
? Math.max(1, Math.round(rawBuff.maxStacks))
: undefined,
} satisfies NonNullable<RuntimePlayerSkill['buildBuffs']>[number];
})
.filter(
(
entry,
): entry is NonNullable<RuntimePlayerSkill['buildBuffs']>[number] =>
Boolean(entry),
),
};
}
function normalizePlayerCharacter(
value: unknown,
): RuntimePlayerCharacter | null {
const rawCharacter = isObject(value) ? value : null;
const rawAttributes = isObject(rawCharacter?.attributes)
? rawCharacter.attributes
: null;
if (!rawCharacter || !rawAttributes) {
return null;
}
return {
attributes: {
strength: Math.max(0, Math.round(readNumber(rawAttributes.strength, 0))),
agility: Math.max(0, Math.round(readNumber(rawAttributes.agility, 0))),
intelligence: Math.max(
0,
Math.round(readNumber(rawAttributes.intelligence, 0)),
),
spirit: Math.max(0, Math.round(readNumber(rawAttributes.spirit, 0))),
},
skills: readArray(rawCharacter.skills)
.map((entry) => normalizePlayerSkill(entry))
.filter((entry): entry is RuntimePlayerSkill => Boolean(entry)),
};
}
function normalizeBattleInventoryItem(
value: unknown,
): RuntimeBattleInventoryItem | null {
const rawItem = isObject(value) ? value : null;
if (!rawItem) {
return null;
}
const id = readString(rawItem.id);
const name = readString(rawItem.name, id);
if (!id || !name) {
return null;
}
const rarity = readString(rawItem.rarity, 'common');
const normalizedRarity =
rarity === 'legendary' ||
rarity === 'epic' ||
rarity === 'rare' ||
rarity === 'uncommon'
? rarity
: 'common';
const useProfile = isObject(rawItem.useProfile)
? (cloneJson(rawItem.useProfile) as RuntimeBattleItemUseProfile)
: undefined;
return {
id,
name,
quantity: Math.max(0, Math.round(readNumber(rawItem.quantity, 0))),
rarity: normalizedRarity,
tags: readArray(rawItem.tags).filter(
(tag): tag is string => typeof tag === 'string' && tag.trim().length > 0,
),
useProfile,
};
}
export function getPlayerCharacter(session: RuntimeSession) {
return normalizePlayerCharacter(session.rawGameState.playerCharacter);
}
export function getPlayerSkillCooldowns(session: RuntimeSession) {
const rawCooldowns = isObject(session.rawGameState.playerSkillCooldowns)
? session.rawGameState.playerSkillCooldowns
: {};
return Object.fromEntries(
Object.entries(rawCooldowns).map(([skillId, turns]) => [
skillId,
Math.max(0, Math.round(readNumber(turns, 0))),
]),
) as Record<string, number>;
}
function getBattleInventoryItems(session: RuntimeSession) {
return readArray(session.rawGameState.playerInventory)
.map((entry) => normalizeBattleInventoryItem(entry))
.filter((entry): entry is RuntimeBattleInventoryItem => Boolean(entry));
}
function buildBasicAttackBaseDamage(character: RuntimePlayerCharacter) {
return Math.max(
8,
Math.round(
character.attributes.strength * 0.85 +
character.attributes.agility * 0.45,
),
);
}
function buildBattleDisabledOption(params: {
functionId: string;
actionText?: string;
detailText?: string;
reason: string;
payload?: RuntimeStoryChoicePayload;
}) {
return buildOptionView(params.functionId, {
actionText: params.actionText,
detailText: params.detailText,
payload: params.payload,
disabled: true,
reason: params.reason,
});
}
function buildBattleItemSummary(
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>,
) {
const parts = [
effect.hpRestore > 0 ? `回血 ${effect.hpRestore}` : null,
effect.manaRestore > 0 ? `回蓝 ${effect.manaRestore}` : null,
effect.cooldownReduction > 0 ? `冷却 -${effect.cooldownReduction}` : null,
effect.buildBuffs.length > 0
? `增益 ${effect.buildBuffs.map((buff) => buff.name).join('、')}`
: null,
].filter(Boolean);
return parts.join(' / ') || '立即结算一次物品效果';
}
function pickPreferredBattleItem(session: RuntimeSession) {
const character = getPlayerCharacter(session);
if (!character) {
return null;
}
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);
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;
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) {
const character = getPlayerCharacter(session);
if (!character) {
return [];
}
const cooldowns = getPlayerSkillCooldowns(session);
return character.skills.map((skill) => {
const remainingCooldown = cooldowns[skill.id] ?? 0;
const damage = resolvePlayerOutgoingDamageResult(
session.rawGameState as Parameters<typeof resolvePlayerOutgoingDamageResult>[0],
character,
skill.damage,
1,
`runtime-skill-preview:${skill.id}`,
).damage;
const detailText = [
`耗蓝 ${skill.manaCost}`,
`伤害 ${damage}`,
`冷却 ${skill.cooldownTurns}`,
].join(' / ');
if (remainingCooldown > 0) {
return buildBattleDisabledOption({
functionId: 'battle_use_skill',
actionText: skill.name,
detailText,
payload: { skillId: skill.id },
reason: `冷却中,还需 ${remainingCooldown} 回合`,
});
}
if (skill.manaCost > session.playerMana) {
return buildBattleDisabledOption({
functionId: 'battle_use_skill',
actionText: skill.name,
detailText,
payload: { skillId: skill.id },
reason: '灵力不足',
});
}
return buildOptionView('battle_use_skill', {
actionText: skill.name,
detailText,
payload: { skillId: skill.id },
});
});
}
function buildBattleActionOptions(session: RuntimeSession) {
const character = getPlayerCharacter(session);
const itemCandidate = pickPreferredBattleItem(session);
const basicAttackDamage = character
? resolvePlayerOutgoingDamageResult(
session.rawGameState as Parameters<typeof resolvePlayerOutgoingDamageResult>[0],
character,
buildBasicAttackBaseDamage(character),
1,
'runtime-basic-attack-preview',
).damage
: 0;
return [
buildOptionView('battle_attack_basic', {
detailText:
basicAttackDamage > 0
? `不耗蓝 / 伤害 ${basicAttackDamage}`
: '不耗蓝的基础攻击',
}),
buildOptionView('battle_recover_breath', {
actionText: '恢复',
detailText: '回血 12 / 回蓝 9 / 冷却 -1',
}),
itemCandidate
? buildOptionView('inventory_use', {
actionText: `使用物品:${itemCandidate.item.name}`,
detailText: buildBattleItemSummary(itemCandidate.effect),
payload: { itemId: itemCandidate.item.id },
})
: buildBattleDisabledOption({
functionId: 'inventory_use',
actionText: '使用物品',
detailText: '当前没有可直接结算的战斗消耗品',
reason: '暂无可用物品',
}),
...buildBattleSkillOptions(session),
buildOptionView('battle_escape_breakout'),
] satisfies RuntimeStoryOptionView[];
}
export function getEncounterKey(encounter: RuntimeEncounter) {
return encounter.id || encounter.npcName;
}
@@ -613,15 +1026,7 @@ function hasGiftablePlayerInventory(session: RuntimeSession) {
export function buildAvailableOptions(session: RuntimeSession) {
if (session.inBattle) {
return [
'battle_probe_pressure',
'battle_guard_break',
'battle_feint_step',
'battle_finisher_window',
'battle_all_in_crush',
'battle_recover_breath',
'battle_escape_breakout',
].map((functionId) => buildOptionView(functionId));
return buildBattleActionOptions(session);
}
if (session.currentEncounter?.kind === 'npc') {
@@ -784,6 +1189,9 @@ export function buildLegacyCurrentStory(
text: option.actionText,
detailText: option.detailText,
priority: option.scope === 'npc' ? 3 : option.scope === 'combat' ? 2 : 1,
runtimePayload: option.payload,
disabled: option.disabled,
disabledReason: option.reason,
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,

View File

@@ -378,46 +378,48 @@ test('runtime story actions resolve combat finishers on the server and collapse
await withTestServer('combat-finisher', async ({ baseUrl }) => {
const entry = await authEntry(baseUrl, 'story_combat_finisher', 'secret123');
await putSnapshot(baseUrl, entry.token, {
worldType: 'WUXIA',
storyHistory: [],
currentEncounter: {
kind: 'npc',
id: 'npc_bandit_01',
npcName: '断桥匪首',
npcDescription: '手提短刀的拦路匪徒',
context: '桥口劫匪',
hostile: true,
},
npcInteractionActive: false,
sceneHostileNpcs: [
{
await putSnapshot(
baseUrl,
entry.token,
createTask6GameState({
currentEncounter: {
kind: 'npc',
id: 'npc_bandit_01',
name: '断桥匪首',
hp: 12,
maxHp: 28,
description: '桥口劫匪',
npcName: '断桥匪首',
npcDescription: '手提短刀的拦路匪徒',
context: '桥口劫匪',
hostile: true,
},
],
inBattle: true,
playerHp: 42,
playerMaxHp: 50,
playerMana: 20,
playerMaxMana: 20,
npcStates: {
npc_bandit_01: {
affinity: -12,
chattedCount: 0,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
npcInteractionActive: false,
sceneHostileNpcs: [
{
id: 'npc_bandit_01',
name: '断桥匪首',
hp: 12,
maxHp: 28,
description: '桥口劫匪',
},
],
inBattle: true,
playerHp: 42,
playerMaxHp: 50,
playerMana: 20,
playerMaxMana: 20,
playerSkillCooldowns: {},
npcStates: {
npc_bandit_01: {
affinity: -12,
chattedCount: 0,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
},
companions: [],
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: null,
});
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: null,
}),
);
const response = await httpRequest(
`${baseUrl}/api/runtime/story/actions/resolve`,
@@ -486,6 +488,313 @@ test('runtime story actions resolve combat finishers on the server and collapse
});
});
test('runtime story state exposes the single-action combat option pool with runtime payload metadata', async () => {
await withTestServer('combat-state-options', async ({ baseUrl }) => {
const entry = await authEntry(baseUrl, 'story_combat_state', 'secret123');
const playerCharacter = {
...requirePlayerCharacter(),
skills: [
{
id: 'slash',
name: '试锋斩',
animation: 'attack',
damage: 18,
manaCost: 4,
cooldownTurns: 2,
range: 1,
style: 'steady',
},
{
id: 'wind-step',
name: '断风步',
animation: 'attack',
damage: 12,
manaCost: 2,
cooldownTurns: 0,
range: 1,
style: 'steady',
},
],
};
await putSnapshot(
baseUrl,
entry.token,
createTask6GameState({
playerCharacter,
currentEncounter: {
kind: 'npc',
id: 'npc_bandit_01',
npcName: '断桥匪首',
npcDescription: '手提短刀的拦路匪徒',
context: '桥口劫匪',
hostile: true,
},
npcInteractionActive: false,
sceneHostileNpcs: [
{
id: 'npc_bandit_01',
name: '断桥匪首',
hp: 36,
maxHp: 36,
description: '桥口劫匪',
},
],
inBattle: true,
playerMana: 6,
playerMaxMana: 16,
playerSkillCooldowns: {
slash: 2,
'wind-step': 0,
},
playerInventory: [
{
id: 'focus-tonic',
category: '消耗品',
name: '凝神灵液',
quantity: 1,
rarity: 'rare',
tags: ['mana'],
useProfile: {
manaRestore: 6,
},
},
],
npcStates: {
npc_bandit_01: {
affinity: -12,
chattedCount: 0,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
currentNpcBattleMode: 'fight',
}),
);
const response = await httpRequest(
`${baseUrl}/api/runtime/story/state/runtime-main`,
{
headers: {
Authorization: `Bearer ${entry.token}`,
},
},
);
const payload = (await response.json()) as {
viewModel: {
status: {
inBattle: boolean;
};
availableOptions: Array<{
functionId: string;
actionText: string;
payload?: {
skillId?: string;
itemId?: string;
};
disabled?: boolean;
reason?: string;
}>;
};
};
assert.equal(response.status, 200);
assert.equal(payload.viewModel.status.inBattle, true);
assert.deepEqual(
payload.viewModel.availableOptions.map((option) => option.functionId),
[
'battle_attack_basic',
'battle_recover_breath',
'inventory_use',
'battle_use_skill',
'battle_use_skill',
'battle_escape_breakout',
],
);
const itemOption = payload.viewModel.availableOptions[2];
assert.equal(itemOption?.functionId, 'inventory_use');
assert.equal(itemOption?.payload?.itemId, 'focus-tonic');
assert.equal(itemOption?.disabled, undefined);
const slashOption = payload.viewModel.availableOptions[3];
assert.equal(slashOption?.actionText, '试锋斩');
assert.equal(slashOption?.payload?.skillId, 'slash');
assert.equal(slashOption?.disabled, true);
assert.match(slashOption?.reason ?? '', //u);
const windStepOption = payload.viewModel.availableOptions[4];
assert.equal(windStepOption?.actionText, '断风步');
assert.equal(windStepOption?.payload?.skillId, 'wind-step');
assert.equal(windStepOption?.disabled, undefined);
});
});
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 playerCharacter = {
...requirePlayerCharacter(),
skills: [
{
id: 'slash',
name: '试锋斩',
animation: 'attack',
damage: 18,
manaCost: 4,
cooldownTurns: 2,
range: 1,
style: 'steady',
buildBuffs: [
{
id: 'slash:buff',
sourceType: 'skill',
sourceId: 'slash',
name: '试锋余势',
tags: ['快剑'],
durationTurns: 2,
},
],
},
],
};
await putSnapshot(
baseUrl,
entry.token,
createTask6GameState({
playerCharacter,
currentEncounter: {
kind: 'npc',
id: 'npc_bandit_01',
npcName: '断桥匪首',
npcDescription: '手提短刀的拦路匪徒',
context: '桥口劫匪',
hostile: true,
},
npcInteractionActive: false,
sceneHostileNpcs: [
{
id: 'npc_bandit_01',
name: '断桥匪首',
hp: 80,
maxHp: 80,
description: '桥口劫匪',
},
],
inBattle: true,
playerHp: 32,
playerMaxHp: 40,
playerMana: 9,
playerMaxMana: 16,
playerSkillCooldowns: {},
activeBuildBuffs: [],
npcStates: {
npc_bandit_01: {
affinity: -12,
chattedCount: 0,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
currentNpcBattleMode: 'fight',
}),
);
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: 'battle_use_skill',
payload: {
skillId: 'slash',
},
},
}),
}),
);
const payload = (await response.json()) as {
serverVersion: number;
viewModel: {
player: {
mana: number;
};
status: {
inBattle: boolean;
};
availableOptions: Array<{
functionId: string;
actionText: string;
payload?: {
skillId?: string;
};
disabled?: boolean;
reason?: string;
}>;
};
presentation: {
resultText: string;
storyText: string;
battle: {
outcome: string;
damageDealt: number;
} | null;
};
snapshot: {
gameState: {
playerMana: number;
playerSkillCooldowns: Record<string, number>;
activeBuildBuffs: Array<{
id: string;
}>;
};
};
patches: Array<{
type: string;
functionId?: string;
}>;
};
assert.equal(response.status, 200);
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.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.ok(
payload.patches.some(
(patch) =>
patch.type === 'battle_resolved' &&
patch.functionId === 'battle_use_skill',
),
);
const skillOption = payload.viewModel.availableOptions.find(
(option) =>
option.functionId === 'battle_use_skill' &&
option.payload?.skillId === 'slash',
);
assert.ok(skillOption);
assert.equal(skillOption.actionText, '试锋斩');
assert.equal(skillOption.disabled, true);
assert.match(skillOption.reason ?? '', //u);
});
});
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');

View File

@@ -155,17 +155,16 @@ 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,
detailText: option.detailText,
visuals: DEFAULT_STORY_OPTION_VISUALS,
interaction: buildStoryOptionInteraction(session, option),
runtimePayload: option.payload,
disabled: option.disabled,
disabledReason: option.reason,
} satisfies JsonRecord;
}
@@ -173,9 +172,7 @@ function buildStoryOptionsFromRuntimeOptions(
session: RuntimeSession,
options: RuntimeStoryOptionView[],
) {
return options
.filter((option) => !option.disabled)
.map((option) => buildStoryOptionFromRuntimeOption(session, option));
return options.map((option) => buildStoryOptionFromRuntimeOption(session, option));
}
function escapeRegExp(value: string) {
@@ -460,6 +457,22 @@ function normalizeStatusPatch(session: RuntimeSession) {
} satisfies RuntimeStoryPatch;
}
function shouldGenerateReasonedCombatStory(
functionId: string,
resolution: StoryResolution,
) {
if (!isCombatFunctionId(functionId)) {
return false;
}
const outcome = resolution.battle?.outcome;
return (
outcome === 'victory' ||
outcome === 'spar_complete' ||
outcome === 'escaped'
);
}
function clearEncounterState(session: RuntimeSession) {
session.currentEncounter = null;
session.npcInteractionActive = false;
@@ -778,7 +791,12 @@ export async function resolveRuntimeStoryAction(params: {
? { ...session.currentEncounter }
: null;
if (isCombatFunctionId(functionId)) {
resolution = resolveCombatAction(session, functionId);
resolution = resolveCombatAction(session, {
functionId,
payload: isObject(params.request.action.payload)
? params.request.action.payload
: undefined,
});
} else if (isNpcFunctionId(functionId)) {
resolution = resolveNpcInteraction(session, functionId);
} else if (isSupportedInventoryStoryFunctionId(functionId)) {
@@ -840,7 +858,10 @@ export async function resolveRuntimeStoryAction(params: {
} catch {
savedCurrentStory = buildLegacyCurrentStory(storyText, options);
}
} else if (params.llmClient && isCombatFunctionId(functionId)) {
} else if (
params.llmClient &&
shouldGenerateReasonedCombatStory(functionId, resolution)
) {
try {
const generatedPayload = await generateReasonedStoryPayload({
llmClient: params.llmClient,