1
This commit is contained in:
@@ -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}有关吗`,
|
||||
'你愿意再说清楚点吗',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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, summary,targetLandmarkName 必须指向本次输出的其他场景名',
|
||||
'',
|
||||
'约束:',
|
||||
'- 所有世界观、角色、场景必须贴合创作者输入,不要套用通用武侠模板。',
|
||||
'- 角色名字、势力名、场景名必须互相区分,避免重复。',
|
||||
'- 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', '设定上下文已整理,开始请求大模型推理。');
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
54
server-node/src/modules/ai/storyPromptBuilders.test.ts
Normal file
54
server-node/src/modules/ai/storyPromptBuilders.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user