1
This commit is contained in:
90
server-node/src/modules/ai/chatOrchestrator.ts
Normal file
90
server-node/src/modules/ai/chatOrchestrator.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import type {
|
||||
CharacterChatReplyRequest,
|
||||
CharacterChatSuggestionsRequest,
|
||||
CharacterChatSummaryRequest,
|
||||
NpcChatDialogueRequest,
|
||||
NpcRecruitDialogueRequest,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import {
|
||||
buildCharacterPanelChatPrompt,
|
||||
buildCharacterPanelChatSuggestionPrompt,
|
||||
buildCharacterPanelChatSummaryPrompt,
|
||||
CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT,
|
||||
CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT,
|
||||
CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
|
||||
buildNpcRecruitDialoguePrompt,
|
||||
buildStrictNpcChatDialoguePrompt,
|
||||
NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
|
||||
NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
|
||||
} from './chatPromptBuilders.js';
|
||||
import type { UpstreamLlmClient } from '../../services/llmClient.js';
|
||||
|
||||
export async function generateCharacterChatSuggestionsFromOrchestrator(
|
||||
llmClient: UpstreamLlmClient,
|
||||
payload: CharacterChatSuggestionsRequest,
|
||||
) {
|
||||
return llmClient.requestMessageContent({
|
||||
systemPrompt: CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT,
|
||||
userPrompt: buildCharacterPanelChatSuggestionPrompt(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateCharacterChatSummaryFromOrchestrator(
|
||||
llmClient: UpstreamLlmClient,
|
||||
payload: CharacterChatSummaryRequest,
|
||||
) {
|
||||
return llmClient.requestMessageContent({
|
||||
systemPrompt: CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT,
|
||||
userPrompt: buildCharacterPanelChatSummaryPrompt(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function streamCharacterChatReplyFromOrchestrator(
|
||||
llmClient: UpstreamLlmClient,
|
||||
params: {
|
||||
request: Request;
|
||||
response: Response;
|
||||
payload: CharacterChatReplyRequest;
|
||||
},
|
||||
) {
|
||||
await llmClient.forwardSseText({
|
||||
request: params.request,
|
||||
response: params.response,
|
||||
systemPrompt: CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
|
||||
userPrompt: buildCharacterPanelChatPrompt(params.payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function streamNpcChatDialogueFromOrchestrator(
|
||||
llmClient: UpstreamLlmClient,
|
||||
params: {
|
||||
request: Request;
|
||||
response: Response;
|
||||
payload: NpcChatDialogueRequest;
|
||||
},
|
||||
) {
|
||||
await llmClient.forwardSseText({
|
||||
request: params.request,
|
||||
response: params.response,
|
||||
systemPrompt: NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
|
||||
userPrompt: buildStrictNpcChatDialoguePrompt(params.payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function streamNpcRecruitDialogueFromOrchestrator(
|
||||
llmClient: UpstreamLlmClient,
|
||||
params: {
|
||||
request: Request;
|
||||
response: Response;
|
||||
payload: NpcRecruitDialogueRequest;
|
||||
},
|
||||
) {
|
||||
await llmClient.forwardSseText({
|
||||
request: params.request,
|
||||
response: params.response,
|
||||
systemPrompt: NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
|
||||
userPrompt: buildNpcRecruitDialoguePrompt(params.payload),
|
||||
});
|
||||
}
|
||||
372
server-node/src/modules/ai/chatPromptBuilders.ts
Normal file
372
server-node/src/modules/ai/chatPromptBuilders.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import type {
|
||||
CharacterChatReplyRequest,
|
||||
CharacterChatSuggestionsRequest,
|
||||
CharacterChatSummaryRequest,
|
||||
NpcChatDialogueRequest,
|
||||
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 次。
|
||||
- 这段对话的目标是把“邀请对方入队”自然谈成。
|
||||
- 不允许出现拒绝入队、继续观望、以后再说、条件未满足等结果。
|
||||
- 不允许出现“我不能答应”“我还没想好”“再让我考虑”“暂时不行”“以后再说”这类拒绝或拖延表述。
|
||||
- 最后一行必须由对方明确答应加入队伍。`;
|
||||
|
||||
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 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 | NpcRecruitDialogueRequest,
|
||||
) {
|
||||
const encounter = describeEncounter(payload.encounter);
|
||||
|
||||
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');
|
||||
}
|
||||
461
server-node/src/modules/ai/customWorldOrchestrator.ts
Normal file
461
server-node/src/modules/ai/customWorldOrchestrator.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
} from '../../../../packages/shared/src/contracts/runtime.js';
|
||||
|
||||
type GeneratedProfile = Record<string, unknown>;
|
||||
|
||||
const PLAYABLE_ROLE_TEMPLATES = [
|
||||
{ title: '断桥行者', role: '游历剑客', style: '快剑追击', tags: ['快剑', '突进', '追击'] },
|
||||
{ title: '听风客', role: '远行弓手', style: '远射游击', tags: ['远射', '游击', '风行'] },
|
||||
{ title: '守夜人', role: '前列护卫', style: '守御护体', tags: ['守御', '护体', '先锋'] },
|
||||
{ title: '观火者', role: '术式使', style: '法修过载', tags: ['法修', '过载', '法力'] },
|
||||
{ title: '逐潮者', role: '浪客拳师', style: '重击压制', tags: ['重击', '爆发', '压制'] },
|
||||
] as const;
|
||||
|
||||
const STORY_ROLE_TEMPLATES = [
|
||||
{ role: '沿街商贩', danger: 'low', tags: ['交易', '情报'] },
|
||||
{ role: '巡路探子', danger: 'medium', tags: ['巡守', '警觉'] },
|
||||
{ role: '旧案见证人', danger: 'medium', tags: ['旧案', '隐情'] },
|
||||
{ role: '守桥武人', danger: 'high', tags: ['守御', '敌意'] },
|
||||
{ role: '异变潜伏者', danger: 'high', tags: ['异变', '威胁'] },
|
||||
] as const;
|
||||
|
||||
const LANDMARK_TEMPLATES = [
|
||||
'断桥口',
|
||||
'旧市桥廊',
|
||||
'潮痕渡口',
|
||||
'灰塔前庭',
|
||||
'沉钟小巷',
|
||||
'碑下荒庭',
|
||||
'雾潮栈道',
|
||||
'封灯码头',
|
||||
'裂潮前哨',
|
||||
'残照高台',
|
||||
] as const;
|
||||
|
||||
function nowMs() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function inferWorldType(settingText: string) {
|
||||
return /仙|灵|宗门|飞升|法器|秘境|星/u.test(settingText)
|
||||
? 'XIANXIA'
|
||||
: 'WUXIA';
|
||||
}
|
||||
|
||||
function seedText(input: GenerateCustomWorldProfileInput) {
|
||||
return input.settingText.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
const normalized = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
return normalized || 'entry';
|
||||
}
|
||||
|
||||
function buildAttributeSchema(worldType: 'WUXIA' | 'XIANXIA') {
|
||||
return {
|
||||
id: `schema:${worldType.toLowerCase()}:default`,
|
||||
worldId: `world:${worldType.toLowerCase()}`,
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType,
|
||||
worldName: worldType === 'XIANXIA' ? '云海异境' : '裂潮边城',
|
||||
settingSummary: worldType === 'XIANXIA' ? '灵潮翻涌的修行世界' : '旧桥与边城交错的武侠世界',
|
||||
tone: worldType === 'XIANXIA' ? '高危、空灵、失衡' : '冷峻、紧绷、江湖余震',
|
||||
conflictCore: '旧秩序与新威胁正在同时逼近',
|
||||
},
|
||||
schemaName: worldType === 'XIANXIA' ? '灵潮六轴' : '边城六轴',
|
||||
slots: [
|
||||
{
|
||||
slotId: 'axis_a',
|
||||
name: '锋势',
|
||||
definition: '临战时的主动压迫与破面能力',
|
||||
positiveSignals: ['先手', '破势'],
|
||||
negativeSignals: ['迟疑', '退缩'],
|
||||
combatUseText: '决定压制与追击能力',
|
||||
socialUseText: '决定发起对峙的胆气',
|
||||
explorationUseText: '决定冒险前推的强度',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_b',
|
||||
name: '守意',
|
||||
definition: '承压、稳住阵脚与保全同伴的能力',
|
||||
positiveSignals: ['护持', '稳守'],
|
||||
negativeSignals: ['失衡', '溃散'],
|
||||
combatUseText: '决定承伤与稳场',
|
||||
socialUseText: '决定是否可靠',
|
||||
explorationUseText: '决定穿越危险区的稳定性',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_c',
|
||||
name: '灵运',
|
||||
definition: '资源调度、法力回转与术式适配能力',
|
||||
positiveSignals: ['回转', '灵感'],
|
||||
negativeSignals: ['枯竭', '滞涩'],
|
||||
combatUseText: '决定灵力和术式运转',
|
||||
socialUseText: '决定理解复杂信息的能力',
|
||||
explorationUseText: '决定破解机关与异象',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_d',
|
||||
name: '机变',
|
||||
definition: '借势应变、换位与局势判断能力',
|
||||
positiveSignals: ['借势', '换位'],
|
||||
negativeSignals: ['僵硬', '迟钝'],
|
||||
combatUseText: '决定机动与变招',
|
||||
socialUseText: '决定读懂弦外之音',
|
||||
explorationUseText: '决定追踪与绕险',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_e',
|
||||
name: '因缘',
|
||||
definition: '人与人之间的牵连、信任与旧债张力',
|
||||
positiveSignals: ['信任', '牵连'],
|
||||
negativeSignals: ['隔阂', '背离'],
|
||||
combatUseText: '决定协同与互援',
|
||||
socialUseText: '决定关系推进',
|
||||
explorationUseText: '决定是否能得到帮助',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_f',
|
||||
name: '秘痕',
|
||||
definition: '旧案、禁忌与隐秘线索的承载程度',
|
||||
positiveSignals: ['旧痕', '秘线'],
|
||||
negativeSignals: ['空白', '浅表'],
|
||||
combatUseText: '决定异象与特殊效果',
|
||||
socialUseText: '决定话题深度',
|
||||
explorationUseText: '决定发现隐藏真相的能力',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildBackstoryReveal(name: string) {
|
||||
return {
|
||||
publicSummary: `${name}在表面上只露出一层足以自保的说辞。`,
|
||||
privateChatUnlockAffinity: 60,
|
||||
chapters: [
|
||||
{
|
||||
id: `${slugify(name)}-surface`,
|
||||
title: '表层来意',
|
||||
affinityRequired: 15,
|
||||
teaser: `${name}对你仍留着一层试探。`,
|
||||
content: `${name}先承认自己并非偶然出现在这里,而是被同一场异动推到了前线。`,
|
||||
contextSnippet: `${name}的真正来意还没有完全摊开。`,
|
||||
},
|
||||
{
|
||||
id: `${slugify(name)}-scar`,
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: 30,
|
||||
teaser: `${name}提到过一次不愿重说的旧伤。`,
|
||||
content: `${name}曾在上一轮风暴里失去过重要的人,因此对类似局势格外警觉。`,
|
||||
contextSnippet: `${name}和旧案之间存在未平的裂痕。`,
|
||||
},
|
||||
{
|
||||
id: `${slugify(name)}-hidden`,
|
||||
title: '隐藏执念',
|
||||
affinityRequired: 60,
|
||||
teaser: `${name}其实一直在盯着更深一层的线索。`,
|
||||
content: `${name}真正想追索的不是眼前纷乱本身,而是它背后那只一直没露面的手。`,
|
||||
contextSnippet: `${name}的行动始终绕着一条更深的暗线。`,
|
||||
},
|
||||
{
|
||||
id: `${slugify(name)}-final`,
|
||||
title: '最终底牌',
|
||||
affinityRequired: 90,
|
||||
teaser: `${name}手里一直留着最后一道底牌。`,
|
||||
content: `${name}早就为最坏结局准备了最后的应对,但不到绝境绝不会轻易亮出。`,
|
||||
contextSnippet: `${name}仍保留着能改写局面的最后筹码。`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildSkills(name: string) {
|
||||
return [
|
||||
{
|
||||
id: `${slugify(name)}-skill-1`,
|
||||
name: `${name}起手`,
|
||||
summary: '先用短促动作压住眼前节奏。',
|
||||
style: '起手压制',
|
||||
},
|
||||
{
|
||||
id: `${slugify(name)}-skill-2`,
|
||||
name: `${name}变招`,
|
||||
summary: '在试探后迅速换位改势。',
|
||||
style: '机动周旋',
|
||||
},
|
||||
{
|
||||
id: `${slugify(name)}-skill-3`,
|
||||
name: `${name}底牌`,
|
||||
summary: '在局势逼紧时打出保留手段。',
|
||||
style: '爆发终结',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildInitialItems(name: string) {
|
||||
return [
|
||||
{
|
||||
id: `${slugify(name)}-item-1`,
|
||||
name: `${name}常备武具`,
|
||||
category: '武器',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '随身不离手的主战物件。',
|
||||
tags: ['战斗', '随身'],
|
||||
},
|
||||
{
|
||||
id: `${slugify(name)}-item-2`,
|
||||
name: `${name}补给包`,
|
||||
category: '消耗品',
|
||||
quantity: 2,
|
||||
rarity: 'uncommon',
|
||||
description: '为了久战和撤离准备的基础补给。',
|
||||
tags: ['补给', '行动'],
|
||||
},
|
||||
{
|
||||
id: `${slugify(name)}-item-3`,
|
||||
name: `${name}私人物件`,
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '不愿轻易交出的旧信物。',
|
||||
tags: ['信物', '线索'],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildPlayableNpcs(seed: string) {
|
||||
return PLAYABLE_ROLE_TEMPLATES.map((template, index) => {
|
||||
const name = `${seed.slice(0, 2) || '裂潮'}${['岚', '砺', '遥', '烛', '澜'][index]}`;
|
||||
return {
|
||||
id: `playable-npc-${index + 1}`,
|
||||
name,
|
||||
title: template.title,
|
||||
role: template.role,
|
||||
description: `${name}习惯先观察再出手,对局势变化反应极快。`,
|
||||
backstory: `${name}长期在风暴边缘活动,对眼前这场失衡局势并不陌生。`,
|
||||
personality: '谨慎、沉稳、保留余地',
|
||||
motivation: '想先查清是谁把局势推到这一步。',
|
||||
combatStyle: template.style,
|
||||
initialAffinity: 18 + index * 4,
|
||||
relationshipHooks: ['共同求生', '交换情报'],
|
||||
tags: [...template.tags],
|
||||
backstoryReveal: buildBackstoryReveal(name),
|
||||
skills: buildSkills(name),
|
||||
initialItems: buildInitialItems(name),
|
||||
templateCharacterId: ['sword-princess', 'archer-hero', 'girl-hero', 'punch-hero', 'fighter-4'][index],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildStoryNpcs(seed: string) {
|
||||
return Array.from({ length: 25 }, (_, index) => {
|
||||
const template = STORY_ROLE_TEMPLATES[index % STORY_ROLE_TEMPLATES.length]!;
|
||||
const name = `${seed.slice(0, 2) || '裂潮'}${['青', '玄', '沉', '洛', '霁'][index % 5]}${index + 1}`;
|
||||
return {
|
||||
id: `story-npc-${index + 1}`,
|
||||
name,
|
||||
title: `第${index + 1}位见证者`,
|
||||
role: template.role,
|
||||
description: `${name}始终在观察这场异动会把谁先逼到台前。`,
|
||||
backstory: `${name}和这片地界的旧事牵连很深,只是还没有把来历说透。`,
|
||||
personality: '警觉、克制、善于藏话',
|
||||
motivation: '想确认这轮动荡背后真正的引线。',
|
||||
combatStyle: template.danger === 'high' ? '先压后断' : '先试后动',
|
||||
initialAffinity: template.danger === 'high' ? -12 : 6 + (index % 3) * 6,
|
||||
relationshipHooks: ['旧案牵连', '局势试探'],
|
||||
tags: [...template.tags],
|
||||
backstoryReveal: buildBackstoryReveal(name),
|
||||
skills: buildSkills(name),
|
||||
initialItems: buildInitialItems(name),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildLandmarks(seed: string, storyNpcIds: string[]) {
|
||||
return LANDMARK_TEMPLATES.map((baseName, index, all) => {
|
||||
const name = `${seed.slice(0, 2) || '裂潮'}${baseName}`;
|
||||
return {
|
||||
id: `landmark-${index + 1}`,
|
||||
name,
|
||||
description: `${name}附近同时压着旧痕、异动与尚未收束的危险。`,
|
||||
dangerLevel: index < 3 ? 'medium' : index < 7 ? 'high' : 'extreme',
|
||||
sceneNpcIds: [
|
||||
storyNpcIds[index % storyNpcIds.length],
|
||||
storyNpcIds[(index + 7) % storyNpcIds.length],
|
||||
storyNpcIds[(index + 13) % storyNpcIds.length],
|
||||
],
|
||||
connections: [
|
||||
{
|
||||
targetLandmarkId: `landmark-${((index + 1) % all.length) + 1}`,
|
||||
relativePosition: 'forward',
|
||||
summary: '沿着当前道路继续前推就能抵达。',
|
||||
},
|
||||
{
|
||||
targetLandmarkId: `landmark-${((index + all.length - 1) % all.length) + 1}`,
|
||||
relativePosition: 'back',
|
||||
summary: '沿原路回撤可以折返到上一处节点。',
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildProgress(
|
||||
phaseId: string,
|
||||
phaseLabel: string,
|
||||
phaseDetail: string,
|
||||
overallProgress: number,
|
||||
activeStepIndex: number,
|
||||
startedAt: number,
|
||||
): CustomWorldGenerationProgress {
|
||||
const steps = [
|
||||
{ id: 'framework', label: '世界框架', detail: '整理世界基础骨架。', status: overallProgress >= 0.25 ? 'completed' : phaseId === 'framework' ? 'active' : 'pending', completed: overallProgress >= 0.25 ? 1 : 0, total: 1 },
|
||||
{ id: 'roles', label: '角色群像', detail: '生成可玩与场景角色。', status: overallProgress >= 0.6 ? 'completed' : phaseId === 'roles' ? 'active' : 'pending', completed: overallProgress >= 0.6 ? 1 : 0, total: 1 },
|
||||
{ id: 'landmarks', label: '场景网络', detail: '生成地标与连接关系。', status: overallProgress >= 0.85 ? 'completed' : phaseId === 'landmarks' ? 'active' : 'pending', completed: overallProgress >= 0.85 ? 1 : 0, total: 1 },
|
||||
{ id: 'finalize', label: '最终归档', detail: '整理最终世界资料。', status: overallProgress >= 1 ? 'completed' : phaseId === 'finalize' ? 'active' : 'pending', completed: overallProgress >= 1 ? 1 : 0, total: 1 },
|
||||
] as CustomWorldGenerationProgress['steps'];
|
||||
|
||||
return {
|
||||
phaseId,
|
||||
phaseLabel,
|
||||
phaseDetail,
|
||||
overallProgress,
|
||||
completedWeight: Math.round(overallProgress * 100),
|
||||
totalWeight: 100,
|
||||
elapsedMs: nowMs() - startedAt,
|
||||
estimatedRemainingMs: overallProgress >= 1 ? 0 : Math.max(1000, Math.round((1 - overallProgress) * 4000)),
|
||||
activeStepIndex,
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
function inferMajorFactions(seed: string) {
|
||||
return [
|
||||
`${seed.slice(0, 2) || '裂潮'}守桥司`,
|
||||
`${seed.slice(0, 2) || '裂潮'}旧案会`,
|
||||
`${seed.slice(0, 2) || '裂潮'}商旅盟`,
|
||||
];
|
||||
}
|
||||
|
||||
function inferCoreConflicts(seedText: string) {
|
||||
const core = seedText.slice(0, 24) || '旧秩序与新威胁的失衡';
|
||||
return [
|
||||
`围绕“${core}”的旧秩序正在松动。`,
|
||||
'各方都在争夺谁来解释眼前的异变。',
|
||||
'真正推动局势的人始终没有完全现身。',
|
||||
];
|
||||
}
|
||||
|
||||
function buildDeterministicProfile(input: GenerateCustomWorldProfileInput) {
|
||||
const setting = seedText(input);
|
||||
const worldType = inferWorldType(setting);
|
||||
const seed = setting.replace(/\s+/g, '').slice(0, 6) || (worldType === 'XIANXIA' ? '云潮' : '裂潮');
|
||||
const playableNpcs = buildPlayableNpcs(seed);
|
||||
const storyNpcs = buildStoryNpcs(seed);
|
||||
const landmarks = buildLandmarks(
|
||||
seed,
|
||||
storyNpcs.map((npc) => npc.id),
|
||||
);
|
||||
|
||||
return {
|
||||
id: `custom-world-${Date.now().toString(36)}-${slugify(seed)}`,
|
||||
settingText: setting,
|
||||
name: worldType === 'XIANXIA' ? `${seed}灵境` : `${seed}边城`,
|
||||
subtitle: '前路未明',
|
||||
summary: `这个世界围绕“${setting.slice(0, 28)}”展开,旧秩序与新威胁正在同时逼近。`,
|
||||
tone: worldType === 'XIANXIA' ? '空灵、危险、失衡' : '冷峻、紧绷、江湖余震',
|
||||
playerGoal: '查清眼前局势的关键矛盾,并守住仍值得相信的人与事',
|
||||
templateWorldType: worldType,
|
||||
majorFactions: inferMajorFactions(seed),
|
||||
coreConflicts: inferCoreConflicts(setting),
|
||||
attributeSchema: buildAttributeSchema(worldType),
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
items: [],
|
||||
camp: {
|
||||
name: worldType === 'XIANXIA' ? `${seed}归云舍` : `${seed}归桥居`,
|
||||
description: '这是玩家开局时暂时安身、整理情报与调整队伍的位置。',
|
||||
dangerLevel: 'low',
|
||||
},
|
||||
landmarks,
|
||||
themePack: null,
|
||||
storyGraph: null,
|
||||
knowledgeFacts: [],
|
||||
threadContracts: [],
|
||||
creatorIntent: input.creatorIntent ?? null,
|
||||
anchorPack: null,
|
||||
lockState: null,
|
||||
ownedSettingLayers: null,
|
||||
generationMode: input.generationMode ?? 'full',
|
||||
generationStatus: input.generationMode === 'fast' ? 'key_only' : 'complete',
|
||||
scenarioPackId: null,
|
||||
campaignPackId: null,
|
||||
} satisfies GeneratedProfile;
|
||||
}
|
||||
|
||||
export async function generateCustomWorldProfileFromOrchestrator(
|
||||
input: GenerateCustomWorldProfileInput,
|
||||
options: {
|
||||
onProgress?: (progress: CustomWorldGenerationProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
) {
|
||||
if (options.signal?.aborted) {
|
||||
throw new Error('世界生成已中断。');
|
||||
}
|
||||
|
||||
const startedAt = nowMs();
|
||||
options.onProgress?.(
|
||||
buildProgress(
|
||||
'framework',
|
||||
'世界框架',
|
||||
'正在整理世界基础设定与主矛盾。',
|
||||
0.2,
|
||||
0,
|
||||
startedAt,
|
||||
),
|
||||
);
|
||||
options.onProgress?.(
|
||||
buildProgress(
|
||||
'roles',
|
||||
'角色群像',
|
||||
'正在生成可扮演角色与场景角色骨架。',
|
||||
0.55,
|
||||
1,
|
||||
startedAt,
|
||||
),
|
||||
);
|
||||
options.onProgress?.(
|
||||
buildProgress(
|
||||
'landmarks',
|
||||
'场景网络',
|
||||
'正在生成地标与场景连接关系。',
|
||||
0.82,
|
||||
2,
|
||||
startedAt,
|
||||
),
|
||||
);
|
||||
|
||||
const profile = buildDeterministicProfile(input);
|
||||
|
||||
options.onProgress?.(
|
||||
buildProgress(
|
||||
'finalize',
|
||||
'最终归档',
|
||||
`世界“${String(profile.name)}”已完成归档。`,
|
||||
1,
|
||||
3,
|
||||
startedAt,
|
||||
),
|
||||
);
|
||||
|
||||
return profile;
|
||||
}
|
||||
193
server-node/src/modules/ai/orchestrator.test.ts
Normal file
193
server-node/src/modules/ai/orchestrator.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type {
|
||||
CharacterChatSuggestionsRequest,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js';
|
||||
import { CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT } from './chatPromptBuilders.js';
|
||||
import { SYSTEM_PROMPT } from './storyPromptBuilders.js';
|
||||
import {
|
||||
generateCharacterChatSuggestionsFromOrchestrator,
|
||||
} from './chatOrchestrator.js';
|
||||
import { generateInitialStoryFromOrchestrator } from './storyOrchestrator.js';
|
||||
|
||||
type TestStoryContext = Parameters<typeof generateInitialStoryFromOrchestrator>[4];
|
||||
type TestStoryOption = Awaited<
|
||||
ReturnType<typeof generateInitialStoryFromOrchestrator>
|
||||
>['options'][number];
|
||||
const TEST_WORLD = 'WUXIA' as Parameters<
|
||||
typeof generateInitialStoryFromOrchestrator
|
||||
>[1];
|
||||
type TestCharacter = Parameters<typeof generateInitialStoryFromOrchestrator>[2];
|
||||
|
||||
function createTestCharacter(overrides: Partial<TestCharacter> = {}) {
|
||||
return {
|
||||
...createTestPlayerCharacter<TestCharacter>(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createStoryContext(): TestStoryContext {
|
||||
return {
|
||||
playerHp: 120,
|
||||
playerMaxHp: 120,
|
||||
playerMana: 40,
|
||||
playerMaxMana: 40,
|
||||
inBattle: false,
|
||||
playerX: 320,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: 'idle',
|
||||
skillCooldowns: {},
|
||||
sceneId: 'inn_room',
|
||||
sceneName: '客栈内室',
|
||||
sceneDescription: '昏黄灯火照着刚刚停下脚步的木桌。',
|
||||
pendingSceneEncounter: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createAvailableOptions(context: TestStoryContext) {
|
||||
void context;
|
||||
return [
|
||||
{
|
||||
functionId: 'idle_explore_forward',
|
||||
actionText: '继续向前探索前路',
|
||||
text: '继续向前探索前路',
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
functionId: 'idle_observe_signs',
|
||||
actionText: '停步观察附近的风吹草动',
|
||||
text: '停步观察附近的风吹草动',
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
] as TestStoryOption[];
|
||||
}
|
||||
|
||||
test('story orchestrator repairs mixed-language narrative on the server side', async () => {
|
||||
const context = createStoryContext();
|
||||
const availableOptions = createAvailableOptions(context);
|
||||
const capturedPrompts: Array<{ systemPrompt: string; userPrompt: string }> = [];
|
||||
const llmClient = {
|
||||
requestMessageContent: async ({
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
}: {
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
}) => {
|
||||
capturedPrompts.push({ systemPrompt, userPrompt });
|
||||
|
||||
if (capturedPrompts.length === 1) {
|
||||
return JSON.stringify({
|
||||
storyText: 'The room falls quiet for a moment.',
|
||||
encounter: null,
|
||||
options: availableOptions.map((option) => ({
|
||||
functionId: option.functionId,
|
||||
actionText: option.actionText,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
storyText: '房间里短暂安静了一瞬,你能听见灯火轻轻噼啪作响。',
|
||||
encounter: null,
|
||||
options: availableOptions.map((option) => ({
|
||||
functionId: option.functionId,
|
||||
actionText: option.actionText,
|
||||
})),
|
||||
});
|
||||
},
|
||||
} as const;
|
||||
|
||||
const response = await generateInitialStoryFromOrchestrator(
|
||||
llmClient as never,
|
||||
TEST_WORLD,
|
||||
createTestCharacter(),
|
||||
[],
|
||||
context,
|
||||
{
|
||||
availableOptions,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(capturedPrompts.length, 2);
|
||||
assert.equal(capturedPrompts[0]?.systemPrompt, SYSTEM_PROMPT);
|
||||
assert.match(capturedPrompts[0]?.userPrompt ?? '', /客栈内室/u);
|
||||
assert.equal(
|
||||
response.storyText,
|
||||
'房间里短暂安静了一瞬,你能听见灯火轻轻噼啪作响。',
|
||||
);
|
||||
assert.deepEqual(
|
||||
response.options.map((option) => option.functionId),
|
||||
availableOptions.map((option) => option.functionId),
|
||||
);
|
||||
});
|
||||
|
||||
test('chat orchestrator builds character suggestion prompts on the server side', async () => {
|
||||
const payload = {
|
||||
worldType: TEST_WORLD,
|
||||
playerCharacter: createTestCharacter(),
|
||||
targetCharacter: createTestCharacter({
|
||||
id: 'test-companion',
|
||||
name: '测试同伴',
|
||||
title: '听风客',
|
||||
}),
|
||||
storyHistory: [],
|
||||
context: createStoryContext(),
|
||||
conversationHistory: [
|
||||
{ speaker: 'player', text: '刚才那阵风是不是也不太对劲?' },
|
||||
{ speaker: 'character', text: '像是有人故意把门帘掀起来了一样。' },
|
||||
],
|
||||
conversationSummary: '两人刚在客栈里察觉到不寻常的动静。',
|
||||
targetStatus: {
|
||||
roleLabel: '同行角色',
|
||||
hp: 95,
|
||||
maxHp: 120,
|
||||
mana: 28,
|
||||
maxMana: 40,
|
||||
affinity: 18,
|
||||
},
|
||||
} satisfies CharacterChatSuggestionsRequest;
|
||||
const capturedPrompts: Array<{ systemPrompt: string; userPrompt: string }> = [];
|
||||
const llmClient = {
|
||||
requestMessageContent: async ({
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
}: {
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
}) => {
|
||||
capturedPrompts.push({ systemPrompt, userPrompt });
|
||||
return '先别急,我们再听一轮。\n你刚才看见谁动门帘了吗?\n要不我先去门边探一眼。';
|
||||
},
|
||||
} as const;
|
||||
|
||||
const text = await generateCharacterChatSuggestionsFromOrchestrator(
|
||||
llmClient as never,
|
||||
payload,
|
||||
);
|
||||
|
||||
assert.equal(text.split('\n').length, 3);
|
||||
assert.equal(
|
||||
capturedPrompts[0]?.systemPrompt,
|
||||
CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT,
|
||||
);
|
||||
assert.match(capturedPrompts[0]?.userPrompt ?? '', /客栈/u);
|
||||
assert.match(capturedPrompts[0]?.userPrompt ?? '', /两人刚在客栈里察觉到不寻常的动静/u);
|
||||
assert.match(capturedPrompts[0]?.userPrompt ?? '', new RegExp(payload.targetCharacter.name, 'u'));
|
||||
});
|
||||
615
server-node/src/modules/ai/storyOrchestrator.ts
Normal file
615
server-node/src/modules/ai/storyOrchestrator.ts
Normal file
@@ -0,0 +1,615 @@
|
||||
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 { buildUserPrompt, SYSTEM_PROMPT } from './storyPromptBuilders.js';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type PromptWorldType = string;
|
||||
type PromptCharacter = JsonRecord;
|
||||
type PromptMonster = JsonRecord;
|
||||
type PromptMonsters = PromptMonster[];
|
||||
type PromptStoryMoment = JsonRecord;
|
||||
type PromptHistory = PromptStoryMoment[];
|
||||
type PromptContext = JsonRecord;
|
||||
type PromptStoryOption = {
|
||||
functionId: string;
|
||||
actionText: string;
|
||||
text?: string;
|
||||
detailText?: string;
|
||||
priority?: number;
|
||||
visuals: {
|
||||
playerAnimation: 'idle' | 'attack' | 'run' | 'hurt' | 'jump' | 'dash';
|
||||
playerMoveMeters: number;
|
||||
playerOffsetY: number;
|
||||
playerFacing: 'left' | 'right';
|
||||
scrollWorld: boolean;
|
||||
monsterChanges: Array<{
|
||||
id: string;
|
||||
action: string;
|
||||
animation: 'idle' | 'move' | 'attack';
|
||||
moveMeters?: number;
|
||||
yOffset?: number;
|
||||
}>;
|
||||
};
|
||||
interaction?: {
|
||||
kind: 'npc' | 'treasure';
|
||||
npcId?: string;
|
||||
action?: string;
|
||||
};
|
||||
skillProbabilities?: Record<string, number>;
|
||||
goalAffordance?: {
|
||||
goalId: string;
|
||||
relation: 'advance' | 'support' | 'detour';
|
||||
label: string;
|
||||
} | null;
|
||||
};
|
||||
type PromptAvailableOptions = PromptStoryOption[];
|
||||
type PromptOptionCatalog = PromptStoryOption[];
|
||||
type StoryRequestOptions = {
|
||||
availableOptions?: PromptAvailableOptions;
|
||||
optionCatalog?: PromptOptionCatalog;
|
||||
};
|
||||
type SceneEncounterResult =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'npc'; npcId?: string }
|
||||
| { kind: 'treasure'; treasureText?: string };
|
||||
type AIResponse = {
|
||||
storyText: string;
|
||||
options: PromptStoryOption[];
|
||||
encounter?: SceneEncounterResult;
|
||||
};
|
||||
|
||||
type RawOptionItem = {
|
||||
functionId: string;
|
||||
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,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right' as const,
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
};
|
||||
|
||||
const STATIC_FALLBACK_OPTION_MAP: Record<
|
||||
string,
|
||||
Partial<PromptStoryOption> & { actionText: string }
|
||||
> = {
|
||||
battle_all_in_crush: { actionText: '正面强压敌人' },
|
||||
battle_escape_breakout: { actionText: '先脱离眼前追杀' },
|
||||
battle_feint_step: { actionText: '借假动作切进身位' },
|
||||
battle_finisher_window: { actionText: '抓住破绽补上终结一击' },
|
||||
battle_guard_break: { actionText: '重击破开对手架势' },
|
||||
battle_probe_pressure: { actionText: '稳扎稳打继续试探' },
|
||||
battle_recover_breath: { actionText: '边守边调息稳住节奏' },
|
||||
idle_call_out: { actionText: '朝前方主动出声试探' },
|
||||
idle_explore_forward: { actionText: '继续向前探索前路' },
|
||||
idle_observe_signs: { actionText: '停步观察附近的风吹草动' },
|
||||
idle_rest_focus: { actionText: '原地调息整理状态' },
|
||||
idle_travel_next_scene: { actionText: '前往相邻场景' },
|
||||
npc_chat: {
|
||||
actionText: '继续交谈',
|
||||
interaction: { kind: 'npc', action: 'chat' },
|
||||
},
|
||||
npc_help: {
|
||||
actionText: '请求援手',
|
||||
interaction: { kind: 'npc', action: 'help' },
|
||||
},
|
||||
npc_fight: {
|
||||
actionText: '直接开战',
|
||||
interaction: { kind: 'npc', action: 'fight' },
|
||||
},
|
||||
npc_leave: {
|
||||
actionText: '先拉开距离',
|
||||
interaction: { kind: 'npc', action: 'leave' },
|
||||
},
|
||||
npc_preview_talk: {
|
||||
actionText: '先试着接一句话',
|
||||
interaction: { kind: 'npc', action: 'chat' },
|
||||
},
|
||||
npc_recruit: {
|
||||
actionText: '正式邀请同行',
|
||||
interaction: { kind: 'npc', action: 'recruit' },
|
||||
},
|
||||
npc_spar: {
|
||||
actionText: '点到为止地切磋',
|
||||
interaction: { kind: 'npc', action: 'spar' },
|
||||
},
|
||||
npc_trade: {
|
||||
actionText: '看看能交换什么',
|
||||
interaction: { kind: 'npc', action: 'trade' },
|
||||
},
|
||||
npc_gift: {
|
||||
actionText: '送上一份礼物',
|
||||
interaction: { kind: 'npc', action: 'gift' },
|
||||
},
|
||||
npc_quest_accept: {
|
||||
actionText: '接下这份委托',
|
||||
interaction: { kind: 'npc', action: 'quest_accept' },
|
||||
},
|
||||
npc_quest_turn_in: {
|
||||
actionText: '交付已经完成的委托',
|
||||
interaction: { kind: 'npc', action: 'quest_turn_in' },
|
||||
},
|
||||
treasure_inspect: {
|
||||
actionText: '仔细检查',
|
||||
interaction: { kind: 'treasure', action: 'inspect' },
|
||||
},
|
||||
treasure_leave: {
|
||||
actionText: '先记下位置',
|
||||
interaction: { kind: 'treasure', action: 'leave' },
|
||||
},
|
||||
treasure_secure: {
|
||||
actionText: '直接收取',
|
||||
interaction: { kind: 'treasure', action: 'secure' },
|
||||
},
|
||||
};
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
function inferNpcId(context: PromptContext, encounter?: SceneEncounterResult) {
|
||||
if (encounter?.kind === 'npc' && encounter.npcId) {
|
||||
return encounter.npcId;
|
||||
}
|
||||
|
||||
return readString(context.encounterId) || readString(context.encounterName);
|
||||
}
|
||||
|
||||
function createGenericOption(params: {
|
||||
functionId: string;
|
||||
actionText?: string;
|
||||
context: PromptContext;
|
||||
encounter?: SceneEncounterResult;
|
||||
}) {
|
||||
const functionId = params.functionId;
|
||||
const preset = STATIC_FALLBACK_OPTION_MAP[functionId];
|
||||
const npcId = inferNpcId(params.context, params.encounter);
|
||||
const interaction =
|
||||
preset?.interaction?.kind === 'npc' && npcId
|
||||
? {
|
||||
...preset.interaction,
|
||||
npcId,
|
||||
}
|
||||
: preset?.interaction;
|
||||
|
||||
return {
|
||||
functionId,
|
||||
actionText: readString(params.actionText) || preset?.actionText || functionId,
|
||||
text: readString(params.actionText) || preset?.actionText || functionId,
|
||||
visuals: DEFAULT_VISUALS,
|
||||
interaction,
|
||||
} satisfies PromptStoryOption;
|
||||
}
|
||||
|
||||
function cloneStoryOption(option: PromptStoryOption): PromptStoryOption {
|
||||
return {
|
||||
...option,
|
||||
visuals: {
|
||||
...DEFAULT_VISUALS,
|
||||
...option.visuals,
|
||||
monsterChanges: option.visuals?.monsterChanges?.map((change) => ({
|
||||
...change,
|
||||
})) ?? [],
|
||||
},
|
||||
interaction: option.interaction ? { ...option.interaction } : undefined,
|
||||
skillProbabilities: option.skillProbabilities
|
||||
? { ...option.skillProbabilities }
|
||||
: undefined,
|
||||
goalAffordance: option.goalAffordance ? { ...option.goalAffordance } : option.goalAffordance,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEncounterResult(
|
||||
raw: unknown,
|
||||
context: PromptContext,
|
||||
): SceneEncounterResult | undefined {
|
||||
if (!context.pendingSceneEncounter) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
const item = raw as Record<string, unknown>;
|
||||
const kind = readString(item.kind);
|
||||
|
||||
if (kind === 'npc' || kind === 'monster') {
|
||||
return {
|
||||
kind: 'npc',
|
||||
npcId: readString(item.npcId) || readString(context.encounterId) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === 'treasure') {
|
||||
return {
|
||||
kind: 'treasure',
|
||||
treasureText: readString(item.treasureText) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
function resolveSafeGeneratedActionText(actionText: string | undefined) {
|
||||
const trimmed = actionText?.trim();
|
||||
if (!trimmed || hasMixedNarrativeLanguage(trimmed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function resolveOptionsFromProvidedOptions(
|
||||
items: RawOptionItem[],
|
||||
availableOptions: PromptAvailableOptions,
|
||||
) {
|
||||
if (items.length === 0) {
|
||||
return availableOptions.map(cloneStoryOption);
|
||||
}
|
||||
|
||||
const optionBuckets = new Map<string, PromptStoryOption[]>();
|
||||
const consumedOptions = new Set<PromptStoryOption>();
|
||||
availableOptions.forEach((option) => {
|
||||
const bucket = optionBuckets.get(option.functionId) ?? [];
|
||||
bucket.push(option);
|
||||
optionBuckets.set(option.functionId, bucket);
|
||||
});
|
||||
|
||||
const resolved: PromptStoryOption[] = [];
|
||||
items.forEach((item) => {
|
||||
const bucket = optionBuckets.get(item.functionId);
|
||||
const matchedOption = bucket?.shift();
|
||||
if (!matchedOption) {
|
||||
return;
|
||||
}
|
||||
consumedOptions.add(matchedOption);
|
||||
|
||||
const rewrittenText = resolveSafeGeneratedActionText(item.actionText);
|
||||
resolved.push({
|
||||
...cloneStoryOption(matchedOption),
|
||||
actionText: rewrittenText || matchedOption.actionText,
|
||||
text: rewrittenText || matchedOption.text || matchedOption.actionText,
|
||||
});
|
||||
});
|
||||
|
||||
if (resolved.length === availableOptions.length) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const remainingOptions = availableOptions.filter(
|
||||
(option) => !consumedOptions.has(option),
|
||||
);
|
||||
return [...resolved, ...remainingOptions.map(cloneStoryOption)];
|
||||
}
|
||||
|
||||
function resolveOptionsFromOptionCatalog(
|
||||
items: RawOptionItem[],
|
||||
optionCatalog: PromptOptionCatalog,
|
||||
context: PromptContext,
|
||||
encounter?: SceneEncounterResult,
|
||||
) {
|
||||
if (items.length === 0) {
|
||||
return optionCatalog.map(cloneStoryOption);
|
||||
}
|
||||
|
||||
const optionBuckets = new Map<string, PromptStoryOption[]>();
|
||||
optionCatalog.forEach((option) => {
|
||||
const bucket = optionBuckets.get(option.functionId) ?? [];
|
||||
bucket.push(option);
|
||||
optionBuckets.set(option.functionId, bucket);
|
||||
});
|
||||
|
||||
return items.map((item) => {
|
||||
const bucket = optionBuckets.get(item.functionId);
|
||||
const matchedOption = bucket?.shift();
|
||||
if (!matchedOption) {
|
||||
return createGenericOption({
|
||||
functionId: item.functionId,
|
||||
actionText: item.actionText,
|
||||
context,
|
||||
encounter,
|
||||
});
|
||||
}
|
||||
|
||||
const rewrittenText = resolveSafeGeneratedActionText(item.actionText);
|
||||
return {
|
||||
...cloneStoryOption(matchedOption),
|
||||
actionText: rewrittenText || matchedOption.actionText,
|
||||
text: rewrittenText || matchedOption.text || matchedOption.actionText,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getFallbackFunctionIds(context: PromptContext, encounter?: SceneEncounterResult) {
|
||||
if (context.inBattle === true) {
|
||||
return [
|
||||
'battle_probe_pressure',
|
||||
'battle_guard_break',
|
||||
'battle_recover_breath',
|
||||
'battle_feint_step',
|
||||
'battle_finisher_window',
|
||||
'battle_escape_breakout',
|
||||
];
|
||||
}
|
||||
|
||||
if (encounter?.kind === 'npc') {
|
||||
return [
|
||||
'npc_chat',
|
||||
'npc_help',
|
||||
'npc_trade',
|
||||
'npc_gift',
|
||||
'npc_recruit',
|
||||
'npc_leave',
|
||||
];
|
||||
}
|
||||
|
||||
if (encounter?.kind === 'treasure') {
|
||||
return ['treasure_inspect', 'treasure_secure', 'treasure_leave'];
|
||||
}
|
||||
|
||||
return [
|
||||
'idle_explore_forward',
|
||||
'idle_call_out',
|
||||
'idle_observe_signs',
|
||||
'idle_rest_focus',
|
||||
'idle_travel_next_scene',
|
||||
'idle_explore_forward',
|
||||
];
|
||||
}
|
||||
|
||||
function getFallbackOptions(
|
||||
context: PromptContext,
|
||||
encounter?: SceneEncounterResult,
|
||||
) {
|
||||
return getFallbackFunctionIds(context, encounter).map((functionId, index) =>
|
||||
createGenericOption({
|
||||
functionId: functionId === 'idle_explore_forward' && index > 0 ? `idle_explore_forward` : functionId,
|
||||
context,
|
||||
encounter,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function buildStoryLanguageFallbackText(context: PromptContext) {
|
||||
if (context.inBattle === true) {
|
||||
return '敌意仍压在眼前,战斗局势还没有真正松开。';
|
||||
}
|
||||
|
||||
if (readString(context.encounterName)) {
|
||||
return `${readString(context.encounterName)}的态度与周围气氛都出现了新的变化,你需要立刻判断接下来如何应对。`;
|
||||
}
|
||||
|
||||
return `${readString(context.sceneName) || '眼前区域'}里的气氛又有了新的变化,你需要继续判断下一步。`;
|
||||
}
|
||||
|
||||
function finalizeStoryNarrativeLanguage(
|
||||
response: AIResponse,
|
||||
context: PromptContext,
|
||||
): AIResponse {
|
||||
if (!needsStoryLanguageRepair(response)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return {
|
||||
...response,
|
||||
storyText: buildStoryLanguageFallbackText(context),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeResponse(
|
||||
raw: unknown,
|
||||
context: PromptContext,
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): AIResponse {
|
||||
const parsedEncounter = normalizeEncounterResult(
|
||||
(raw as Record<string, unknown> | null)?.encounter,
|
||||
context,
|
||||
);
|
||||
const fallbackOptions =
|
||||
requestOptions.availableOptions?.map(cloneStoryOption) ??
|
||||
requestOptions.optionCatalog?.map(cloneStoryOption) ??
|
||||
getFallbackOptions(context, parsedEncounter);
|
||||
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return {
|
||||
storyText:
|
||||
context.inBattle === true
|
||||
? '前方敌意仍在持续逼近,局势只允许继续交锋或抽身脱离。'
|
||||
: '周围暂时平静下来,你可以继续探索或前往别处。',
|
||||
options: fallbackOptions,
|
||||
encounter: parsedEncounter,
|
||||
};
|
||||
}
|
||||
|
||||
const data = raw as Record<string, unknown>;
|
||||
const rawOptions = Array.isArray(data.options) ? data.options : [];
|
||||
const optionItems = rawOptions
|
||||
.map((option) => {
|
||||
if (!option || typeof option !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const item = option as Record<string, unknown>;
|
||||
const functionId = readString(item.functionId);
|
||||
if (!functionId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
functionId,
|
||||
actionText: readString(item.actionText) || undefined,
|
||||
} satisfies RawOptionItem;
|
||||
})
|
||||
.filter(Boolean) as RawOptionItem[];
|
||||
|
||||
const options = requestOptions.availableOptions
|
||||
? resolveOptionsFromProvidedOptions(optionItems, requestOptions.availableOptions)
|
||||
: requestOptions.optionCatalog
|
||||
? resolveOptionsFromOptionCatalog(
|
||||
optionItems,
|
||||
requestOptions.optionCatalog,
|
||||
context,
|
||||
parsedEncounter,
|
||||
)
|
||||
: optionItems.length > 0
|
||||
? optionItems.map((item) =>
|
||||
createGenericOption({
|
||||
functionId: item.functionId,
|
||||
actionText: item.actionText,
|
||||
context,
|
||||
encounter: parsedEncounter,
|
||||
}),
|
||||
)
|
||||
: fallbackOptions;
|
||||
|
||||
return {
|
||||
storyText:
|
||||
readString(data.storyText) ||
|
||||
(context.inBattle === true
|
||||
? '敌人仍在前方压迫而来,战斗还没有结束。'
|
||||
: '前路重新安静下来,可以继续决定接下来的探索方向。'),
|
||||
options: options.length > 0 ? options : fallbackOptions,
|
||||
encounter: parsedEncounter,
|
||||
};
|
||||
}
|
||||
|
||||
async function repairStoryNarrativeLanguage(
|
||||
llmClient: UpstreamLlmClient,
|
||||
response: AIResponse,
|
||||
context: PromptContext,
|
||||
requestOptions: StoryRequestOptions,
|
||||
) {
|
||||
if (!needsStoryLanguageRepair(response)) {
|
||||
return finalizeStoryNarrativeLanguage(response, context);
|
||||
}
|
||||
|
||||
try {
|
||||
const repairedContent = await llmClient.requestMessageContent({
|
||||
systemPrompt: STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT,
|
||||
userPrompt: buildStoryLanguageRepairPrompt(response),
|
||||
});
|
||||
const repairedResponse = normalizeResponse(
|
||||
parseJsonResponseText(repairedContent),
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
return finalizeStoryNarrativeLanguage(repairedResponse, context);
|
||||
} catch (error) {
|
||||
llmClient.logger.warn(
|
||||
{
|
||||
err: error,
|
||||
},
|
||||
'story narrative language repair failed',
|
||||
);
|
||||
return finalizeStoryNarrativeLanguage(response, context);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestStoryCompletion(
|
||||
llmClient: UpstreamLlmClient,
|
||||
params: {
|
||||
worldType: PromptWorldType;
|
||||
character: PromptCharacter;
|
||||
monsters: PromptMonsters;
|
||||
history: PromptHistory;
|
||||
choice?: string;
|
||||
context: PromptContext;
|
||||
requestOptions?: StoryRequestOptions;
|
||||
},
|
||||
) {
|
||||
const content = await llmClient.requestMessageContent({
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
userPrompt: buildUserPrompt({
|
||||
worldType: params.worldType,
|
||||
character: params.character,
|
||||
monsters: params.monsters,
|
||||
history: params.history,
|
||||
context: params.context,
|
||||
choice: params.choice,
|
||||
requestOptions: params.requestOptions,
|
||||
}),
|
||||
});
|
||||
const response = normalizeResponse(
|
||||
parseJsonResponseText(content),
|
||||
params.context,
|
||||
params.requestOptions,
|
||||
);
|
||||
|
||||
return repairStoryNarrativeLanguage(
|
||||
llmClient,
|
||||
response,
|
||||
params.context,
|
||||
params.requestOptions ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateInitialStoryFromOrchestrator(
|
||||
llmClient: UpstreamLlmClient,
|
||||
worldType: PromptWorldType,
|
||||
character: PromptCharacter,
|
||||
monsters: PromptMonsters,
|
||||
context: PromptContext,
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
) {
|
||||
return requestStoryCompletion(llmClient, {
|
||||
worldType,
|
||||
character,
|
||||
monsters,
|
||||
history: [],
|
||||
context,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateNextStoryFromOrchestrator(
|
||||
llmClient: UpstreamLlmClient,
|
||||
worldType: PromptWorldType,
|
||||
character: PromptCharacter,
|
||||
monsters: PromptMonsters,
|
||||
history: PromptHistory,
|
||||
choice: string,
|
||||
context: PromptContext,
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
) {
|
||||
return requestStoryCompletion(llmClient, {
|
||||
worldType,
|
||||
character,
|
||||
monsters,
|
||||
history,
|
||||
choice,
|
||||
context,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
163
server-node/src/modules/ai/storyPromptBuilders.ts
Normal file
163
server-node/src/modules/ai/storyPromptBuilders.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
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');
|
||||
}
|
||||
2505
server-node/src/modules/assets/characterAssetRoutes.ts
Normal file
2505
server-node/src/modules/assets/characterAssetRoutes.ts
Normal file
File diff suppressed because it is too large
Load Diff
907
server-node/src/modules/assets/qwenSpriteRoutes.ts
Normal file
907
server-node/src/modules/assets/qwenSpriteRoutes.ts
Normal file
@@ -0,0 +1,907 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import http, {
|
||||
type IncomingMessage,
|
||||
type RequestOptions,
|
||||
type ServerResponse,
|
||||
} from 'node:http';
|
||||
import https from 'node:https';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Router, type NextFunction, type Request, type Response } from 'express';
|
||||
import type { AppConfig } from '../../config.js';
|
||||
|
||||
const QWEN_SPRITE_MASTER_GENERATE_PATH = '/api/assets/qwen-sprite/master';
|
||||
const QWEN_SPRITE_SHEET_GENERATE_PATH = '/api/assets/qwen-sprite/sheet';
|
||||
const QWEN_SPRITE_FRAME_REPAIR_PATH = '/api/assets/qwen-sprite/frame-repair';
|
||||
const QWEN_SPRITE_SAVE_PATH = '/api/assets/qwen-sprite/save';
|
||||
const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1';
|
||||
const DEFAULT_QWEN_IMAGE_MODEL = 'qwen-image-2.0';
|
||||
|
||||
function readJsonBody(req: IncomingMessage & { body?: unknown }) {
|
||||
const parsedBody = req.body;
|
||||
if (parsedBody && typeof parsedBody === 'object' && !Array.isArray(parsedBody)) {
|
||||
return Promise.resolve(parsedBody as Record<string, unknown>);
|
||||
}
|
||||
|
||||
return new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const raw =
|
||||
Buffer.concat(chunks)
|
||||
.toString('utf8')
|
||||
.replace(/^\uFEFF/u, '') || '{}';
|
||||
resolve(JSON.parse(raw));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, statusCode: number, payload: unknown) {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function isRecordValue(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every((item) => typeof item === 'string' && item.trim().length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRuntimeEnv(config: AppConfig) {
|
||||
return config.rawEnv;
|
||||
}
|
||||
|
||||
function normalizeDashScopeBaseUrl(value: string) {
|
||||
return value.replace(/\/$/u, '');
|
||||
}
|
||||
|
||||
function extractApiErrorMessage(responseText: string, fallbackMessage: string) {
|
||||
if (!responseText.trim()) {
|
||||
return fallbackMessage;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(responseText) as {
|
||||
code?: string;
|
||||
message?: string;
|
||||
error?: { message?: string };
|
||||
};
|
||||
if (
|
||||
typeof parsed.error?.message === 'string' &&
|
||||
parsed.error.message.trim()
|
||||
) {
|
||||
return parsed.error.message;
|
||||
}
|
||||
if (typeof parsed.message === 'string' && parsed.message.trim()) {
|
||||
return parsed.message;
|
||||
}
|
||||
if (typeof parsed.code === 'string' && parsed.code.trim()) {
|
||||
return `${fallbackMessage} (${parsed.code})`;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to raw text.
|
||||
}
|
||||
|
||||
return responseText;
|
||||
}
|
||||
|
||||
function sanitizePathSegment(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-_]+/gu, '-')
|
||||
.replace(/-+/gu, '-')
|
||||
.replace(/^-|-$/gu, '');
|
||||
|
||||
return normalized || 'asset';
|
||||
}
|
||||
|
||||
function createTimestampId(prefix: string) {
|
||||
return `${prefix}-${Date.now()}`;
|
||||
}
|
||||
|
||||
function requestTextResponse(
|
||||
urlString: string,
|
||||
options: {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
bodyText?: string;
|
||||
} = {},
|
||||
) {
|
||||
return new Promise<{
|
||||
statusCode: number;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
bodyText: string;
|
||||
}>((resolve, reject) => {
|
||||
const url = new URL(urlString);
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
const payload = options.bodyText;
|
||||
const requestOptions: RequestOptions = {
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port ? Number(url.port) : undefined,
|
||||
path: `${url.pathname}${url.search}`,
|
||||
method: options.method ?? 'GET',
|
||||
headers: {
|
||||
...(options.headers ?? {}),
|
||||
...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
const request = transport.request(requestOptions, (upstreamRes) => {
|
||||
const chunks: Buffer[] = [];
|
||||
upstreamRes.on('data', (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
upstreamRes.on('end', () => {
|
||||
resolve({
|
||||
statusCode: upstreamRes.statusCode ?? 502,
|
||||
headers: upstreamRes.headers,
|
||||
bodyText: Buffer.concat(chunks).toString('utf8'),
|
||||
});
|
||||
});
|
||||
upstreamRes.on('error', reject);
|
||||
});
|
||||
|
||||
request.on('error', reject);
|
||||
if (payload) {
|
||||
request.write(payload);
|
||||
}
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function requestBinaryResponse(
|
||||
urlString: string,
|
||||
options: {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
} = {},
|
||||
) {
|
||||
return new Promise<{
|
||||
statusCode: number;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
body: Buffer;
|
||||
}>((resolve, reject) => {
|
||||
const url = new URL(urlString);
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
const requestOptions: RequestOptions = {
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port ? Number(url.port) : undefined,
|
||||
path: `${url.pathname}${url.search}`,
|
||||
method: options.method ?? 'GET',
|
||||
headers: options.headers ?? {},
|
||||
};
|
||||
|
||||
const request = transport.request(requestOptions, (upstreamRes) => {
|
||||
const chunks: Buffer[] = [];
|
||||
upstreamRes.on('data', (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
upstreamRes.on('end', () => {
|
||||
resolve({
|
||||
statusCode: upstreamRes.statusCode ?? 502,
|
||||
headers: upstreamRes.headers,
|
||||
body: Buffer.concat(chunks),
|
||||
});
|
||||
});
|
||||
upstreamRes.on('error', reject);
|
||||
});
|
||||
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function proxyJsonRequest(
|
||||
urlString: string,
|
||||
apiKey: string,
|
||||
body: Record<string, unknown>,
|
||||
) {
|
||||
return requestTextResponse(urlString, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
bodyText: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function collectStringsByKey(
|
||||
value: unknown,
|
||||
targetKey: string,
|
||||
results: string[],
|
||||
) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => collectStringsByKey(item, targetKey, results));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRecordValue(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const directValue = value[targetKey];
|
||||
if (typeof directValue === 'string' && directValue.trim()) {
|
||||
results.push(directValue.trim());
|
||||
}
|
||||
|
||||
Object.values(value).forEach((nestedValue) =>
|
||||
collectStringsByKey(nestedValue, targetKey, results),
|
||||
);
|
||||
}
|
||||
|
||||
function extractImageUrls(payload: Record<string, unknown>) {
|
||||
const results: string[] = [];
|
||||
collectStringsByKey(payload.output, 'image', results);
|
||||
collectStringsByKey(payload.output, 'url', results);
|
||||
return [...new Set(results)];
|
||||
}
|
||||
|
||||
function parseDataUrl(source: string) {
|
||||
const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source);
|
||||
if (!matched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mimeType = matched[1];
|
||||
const base64Payload = matched[2];
|
||||
const extension = (() => {
|
||||
switch (mimeType) {
|
||||
case 'image/jpeg':
|
||||
return 'jpg';
|
||||
case 'image/webp':
|
||||
return 'webp';
|
||||
default:
|
||||
return 'png';
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
buffer: Buffer.from(base64Payload, 'base64'),
|
||||
extension,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveImageSourcePayload(rootDir: string, source: string) {
|
||||
const parsedDataUrl = parseDataUrl(source);
|
||||
if (parsedDataUrl) {
|
||||
return parsedDataUrl;
|
||||
}
|
||||
|
||||
if (!source.startsWith('/')) {
|
||||
throw new Error('图像来源必须是 Data URL 或 public 目录 URL。');
|
||||
}
|
||||
|
||||
const normalizedSource = path.posix.normalize(source).replace(/^\/+/u, '');
|
||||
const absolutePath = path.resolve(
|
||||
rootDir,
|
||||
'public',
|
||||
...normalizedSource.split('/'),
|
||||
);
|
||||
const publicRoot = path.resolve(rootDir, 'public');
|
||||
|
||||
if (!absolutePath.startsWith(publicRoot)) {
|
||||
throw new Error('图像来源路径越界。');
|
||||
}
|
||||
|
||||
const buffer = await readFile(absolutePath);
|
||||
const extension = path.extname(absolutePath).replace(/^\./u, '') || 'png';
|
||||
|
||||
return {
|
||||
buffer,
|
||||
extension,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveImageSourceAsDataUrl(rootDir: string, source: string) {
|
||||
if (/^data:image\/[^;]+;base64,/u.test(source)) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const payload = await resolveImageSourcePayload(rootDir, source);
|
||||
const mimeType = (() => {
|
||||
switch (payload.extension) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return 'image/jpeg';
|
||||
case 'webp':
|
||||
return 'image/webp';
|
||||
default:
|
||||
return 'image/png';
|
||||
}
|
||||
})();
|
||||
|
||||
return `data:${mimeType};base64,${payload.buffer.toString('base64')}`;
|
||||
}
|
||||
|
||||
async function writeDraftImageFile(
|
||||
rootDir: string,
|
||||
relativePath: string,
|
||||
buffer: Buffer,
|
||||
) {
|
||||
const absolutePath = path.resolve(rootDir, 'public', ...relativePath.split('/'));
|
||||
await mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await writeFile(absolutePath, buffer);
|
||||
return `/${relativePath}`;
|
||||
}
|
||||
|
||||
async function generateQwenImages(
|
||||
config: AppConfig,
|
||||
input: {
|
||||
kind: 'master' | 'sheet' | 'repair';
|
||||
promptText: string;
|
||||
negativePrompt: string;
|
||||
model: string;
|
||||
size: string;
|
||||
promptExtend: boolean;
|
||||
seed?: number;
|
||||
candidateCount: number;
|
||||
referenceImages: string[];
|
||||
},
|
||||
) {
|
||||
const rootDir = config.projectRoot;
|
||||
const runtimeEnv = resolveRuntimeEnv(config);
|
||||
const baseUrl = normalizeDashScopeBaseUrl(
|
||||
runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL,
|
||||
);
|
||||
const apiKey = runtimeEnv.DASHSCOPE_API_KEY || '';
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('服务端缺少 DASHSCOPE_API_KEY,无法调用 Qwen-Image。');
|
||||
}
|
||||
|
||||
const content = [
|
||||
...(await Promise.all(
|
||||
input.referenceImages
|
||||
.slice(0, 3)
|
||||
.map(async (image) => ({ image: await resolveImageSourceAsDataUrl(rootDir, image) })),
|
||||
)),
|
||||
{ text: input.promptText },
|
||||
];
|
||||
|
||||
const requestPayload: Record<string, unknown> = {
|
||||
model: input.model || DEFAULT_QWEN_IMAGE_MODEL,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content,
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
n: Math.max(1, Math.min(6, input.candidateCount)),
|
||||
negative_prompt: input.negativePrompt,
|
||||
prompt_extend: input.promptExtend,
|
||||
watermark: false,
|
||||
size: input.size,
|
||||
...(typeof input.seed === 'number' && Number.isFinite(input.seed)
|
||||
? { seed: input.seed }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
|
||||
const response = await proxyJsonRequest(
|
||||
`${baseUrl}/services/aigc/multimodal-generation/generation`,
|
||||
apiKey,
|
||||
requestPayload,
|
||||
);
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw new Error(
|
||||
extractApiErrorMessage(response.bodyText, 'Qwen-Image 生成失败。'),
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(response.bodyText) as Record<string, unknown>;
|
||||
const imageUrls = extractImageUrls(parsed);
|
||||
|
||||
if (imageUrls.length === 0) {
|
||||
throw new Error('Qwen-Image 未返回可下载的图片结果。');
|
||||
}
|
||||
|
||||
const draftId = createTimestampId(`qwen-${input.kind}`);
|
||||
const relativeDir = path.posix.join(
|
||||
'generated-qwen-sprites',
|
||||
'_drafts',
|
||||
input.kind,
|
||||
draftId,
|
||||
);
|
||||
|
||||
const drafts = await Promise.all(
|
||||
imageUrls.map(async (imageUrl, index) => {
|
||||
const binaryResponse = await requestBinaryResponse(imageUrl);
|
||||
if (
|
||||
binaryResponse.statusCode < 200 ||
|
||||
binaryResponse.statusCode >= 300
|
||||
) {
|
||||
throw new Error(`下载生成图片失败(${binaryResponse.statusCode})。`);
|
||||
}
|
||||
|
||||
const imageSrc = await writeDraftImageFile(
|
||||
rootDir,
|
||||
path.posix.join(relativeDir, `candidate-${String(index + 1).padStart(2, '0')}.png`),
|
||||
binaryResponse.body,
|
||||
);
|
||||
|
||||
return {
|
||||
id: `${draftId}-${index + 1}`,
|
||||
label: `${input.kind === 'master' ? '主图' : input.kind === 'sheet' ? '精灵表' : '修帧'} ${index + 1}`,
|
||||
imageSrc,
|
||||
remoteUrl: imageUrl,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
path.resolve(rootDir, 'public', ...relativeDir.split('/'), 'job.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
draftId,
|
||||
kind: input.kind,
|
||||
model: input.model,
|
||||
size: input.size,
|
||||
promptText: input.promptText,
|
||||
negativePrompt: input.negativePrompt,
|
||||
promptExtend: input.promptExtend,
|
||||
seed: input.seed,
|
||||
candidateCount: input.candidateCount,
|
||||
referenceImageCount: input.referenceImages.length,
|
||||
drafts,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
return {
|
||||
draftId,
|
||||
drafts,
|
||||
model: input.model,
|
||||
size: input.size,
|
||||
promptText: input.promptText,
|
||||
negativePrompt: input.negativePrompt,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleGenerateMaster(
|
||||
config: AppConfig,
|
||||
req: IncomingMessage & { body?: unknown },
|
||||
res: ServerResponse,
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
|
||||
return;
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await readJsonBody(req);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const promptText =
|
||||
typeof body.promptText === 'string' ? body.promptText.trim() : '';
|
||||
const negativePrompt =
|
||||
typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : '';
|
||||
const model =
|
||||
typeof body.model === 'string' && body.model.trim()
|
||||
? body.model.trim()
|
||||
: DEFAULT_QWEN_IMAGE_MODEL;
|
||||
const size =
|
||||
typeof body.size === 'string' && body.size.trim()
|
||||
? body.size.trim()
|
||||
: '1024*1024';
|
||||
const promptExtend = body.promptExtend !== false;
|
||||
const candidateCount =
|
||||
typeof body.candidateCount === 'number' && Number.isFinite(body.candidateCount)
|
||||
? body.candidateCount
|
||||
: 1;
|
||||
const seed =
|
||||
typeof body.seed === 'number' && Number.isFinite(body.seed)
|
||||
? body.seed
|
||||
: undefined;
|
||||
const referenceImages = isStringArray(body.referenceImages)
|
||||
? body.referenceImages
|
||||
: [];
|
||||
|
||||
if (!promptText) {
|
||||
sendJson(res, 400, { error: { message: 'promptText is required.' } });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateQwenImages(config, {
|
||||
kind: 'master',
|
||||
promptText,
|
||||
negativePrompt,
|
||||
model,
|
||||
size,
|
||||
promptExtend,
|
||||
seed,
|
||||
candidateCount,
|
||||
referenceImages,
|
||||
});
|
||||
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, {
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : '生成主图失败。',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateSheet(
|
||||
config: AppConfig,
|
||||
req: IncomingMessage & { body?: unknown },
|
||||
res: ServerResponse,
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
|
||||
return;
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await readJsonBody(req);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const promptText =
|
||||
typeof body.promptText === 'string' ? body.promptText.trim() : '';
|
||||
const negativePrompt =
|
||||
typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : '';
|
||||
const model =
|
||||
typeof body.model === 'string' && body.model.trim()
|
||||
? body.model.trim()
|
||||
: DEFAULT_QWEN_IMAGE_MODEL;
|
||||
const size =
|
||||
typeof body.size === 'string' && body.size.trim()
|
||||
? body.size.trim()
|
||||
: '1024*1024';
|
||||
const promptExtend = body.promptExtend !== false;
|
||||
const candidateCount =
|
||||
typeof body.candidateCount === 'number' && Number.isFinite(body.candidateCount)
|
||||
? body.candidateCount
|
||||
: 1;
|
||||
const seed =
|
||||
typeof body.seed === 'number' && Number.isFinite(body.seed)
|
||||
? body.seed
|
||||
: undefined;
|
||||
const referenceImages = isStringArray(body.referenceImages)
|
||||
? body.referenceImages
|
||||
: [];
|
||||
|
||||
if (!promptText) {
|
||||
sendJson(res, 400, { error: { message: 'promptText is required.' } });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateQwenImages(config, {
|
||||
kind: 'sheet',
|
||||
promptText,
|
||||
negativePrompt,
|
||||
model,
|
||||
size,
|
||||
promptExtend,
|
||||
seed,
|
||||
candidateCount,
|
||||
referenceImages,
|
||||
});
|
||||
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, {
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : '生成精灵表失败。',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRepairFrame(
|
||||
config: AppConfig,
|
||||
req: IncomingMessage & { body?: unknown },
|
||||
res: ServerResponse,
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
|
||||
return;
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await readJsonBody(req);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const promptText =
|
||||
typeof body.promptText === 'string' ? body.promptText.trim() : '';
|
||||
const negativePrompt =
|
||||
typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : '';
|
||||
const model =
|
||||
typeof body.model === 'string' && body.model.trim()
|
||||
? body.model.trim()
|
||||
: DEFAULT_QWEN_IMAGE_MODEL;
|
||||
const size =
|
||||
typeof body.size === 'string' && body.size.trim()
|
||||
? body.size.trim()
|
||||
: '512*512';
|
||||
const promptExtend = body.promptExtend !== false;
|
||||
const seed =
|
||||
typeof body.seed === 'number' && Number.isFinite(body.seed)
|
||||
? body.seed
|
||||
: undefined;
|
||||
const referenceImages = isStringArray(body.referenceImages)
|
||||
? body.referenceImages
|
||||
: [];
|
||||
|
||||
if (!promptText) {
|
||||
sendJson(res, 400, { error: { message: 'promptText is required.' } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (referenceImages.length === 0) {
|
||||
sendJson(res, 400, {
|
||||
error: { message: '至少需要一张参考图来修复帧。' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateQwenImages(config, {
|
||||
kind: 'repair',
|
||||
promptText,
|
||||
negativePrompt,
|
||||
model,
|
||||
size,
|
||||
promptExtend,
|
||||
seed,
|
||||
candidateCount: 1,
|
||||
referenceImages,
|
||||
});
|
||||
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
...result,
|
||||
repairedFrame: result.drafts[0] ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, {
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : '修帧失败。',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveAsset(
|
||||
rootDir: string,
|
||||
req: IncomingMessage & { body?: unknown },
|
||||
res: ServerResponse,
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
|
||||
return;
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await readJsonBody(req);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const assetKey =
|
||||
typeof body.assetKey === 'string' ? sanitizePathSegment(body.assetKey) : '';
|
||||
const actionKey =
|
||||
typeof body.actionKey === 'string' ? sanitizePathSegment(body.actionKey) : '';
|
||||
const masterSource =
|
||||
typeof body.masterSource === 'string' ? body.masterSource.trim() : '';
|
||||
const sheetSource =
|
||||
typeof body.sheetSource === 'string' ? body.sheetSource.trim() : '';
|
||||
const framesDataUrls = isStringArray(body.framesDataUrls)
|
||||
? body.framesDataUrls
|
||||
: [];
|
||||
const metadata = isRecordValue(body.metadata) ? body.metadata : {};
|
||||
const prompts = isRecordValue(body.prompts) ? body.prompts : {};
|
||||
|
||||
if (!assetKey) {
|
||||
sendJson(res, 400, { error: { message: 'assetKey is required.' } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!actionKey) {
|
||||
sendJson(res, 400, { error: { message: 'actionKey is required.' } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sheetSource) {
|
||||
sendJson(res, 400, { error: { message: 'sheetSource is required.' } });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const assetId = createTimestampId('qwen-sprite');
|
||||
const relativeDir = path.posix.join(
|
||||
'generated-qwen-sprites',
|
||||
assetKey,
|
||||
actionKey,
|
||||
assetId,
|
||||
);
|
||||
const absoluteDir = path.resolve(rootDir, 'public', ...relativeDir.split('/'));
|
||||
await mkdir(path.join(absoluteDir, 'frames'), { recursive: true });
|
||||
|
||||
let masterImagePath: string | null = null;
|
||||
if (masterSource) {
|
||||
const payload = await resolveImageSourcePayload(rootDir, masterSource);
|
||||
masterImagePath = await writeDraftImageFile(
|
||||
rootDir,
|
||||
path.posix.join(relativeDir, `master.${payload.extension}`),
|
||||
payload.buffer,
|
||||
);
|
||||
}
|
||||
|
||||
const sheetPayload = await resolveImageSourcePayload(rootDir, sheetSource);
|
||||
const sheetImagePath = await writeDraftImageFile(
|
||||
rootDir,
|
||||
path.posix.join(relativeDir, `sheet.${sheetPayload.extension}`),
|
||||
sheetPayload.buffer,
|
||||
);
|
||||
|
||||
const framePaths: string[] = [];
|
||||
for (let index = 0; index < framesDataUrls.length; index += 1) {
|
||||
const framePayload = await resolveImageSourcePayload(
|
||||
rootDir,
|
||||
framesDataUrls[index] ?? '',
|
||||
);
|
||||
const framePath = await writeDraftImageFile(
|
||||
rootDir,
|
||||
path.posix.join(
|
||||
relativeDir,
|
||||
'frames',
|
||||
`frame-${String(index + 1).padStart(2, '0')}.${framePayload.extension}`,
|
||||
),
|
||||
framePayload.buffer,
|
||||
);
|
||||
framePaths.push(framePath);
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
path.join(absoluteDir, 'metadata.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
assetId,
|
||||
assetKey,
|
||||
actionKey,
|
||||
masterImagePath,
|
||||
sheetImagePath,
|
||||
framePaths,
|
||||
metadata,
|
||||
prompts,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
assetId,
|
||||
assetDir: `/${relativeDir}`,
|
||||
masterImagePath,
|
||||
sheetImagePath,
|
||||
framePaths,
|
||||
saveMessage: '已保存到 public/generated-qwen-sprites。',
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, {
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : '保存精灵表资产失败。',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toExpressHandler(
|
||||
handler: (
|
||||
request: IncomingMessage & { body?: unknown },
|
||||
response: ServerResponse,
|
||||
) => Promise<void> | void,
|
||||
) {
|
||||
return (request: Request, response: Response, next: NextFunction) => {
|
||||
Promise.resolve(
|
||||
handler(
|
||||
request as Request & IncomingMessage & { body?: unknown },
|
||||
response as Response & ServerResponse,
|
||||
),
|
||||
).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
export function createQwenSpriteRoutes(config: AppConfig) {
|
||||
const router = Router();
|
||||
|
||||
router.use((request, response, next) => {
|
||||
if (
|
||||
request.path !== '/api/assets' &&
|
||||
!request.path.startsWith('/api/assets/')
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.assetsApiEnabled) {
|
||||
response.status(403).json({
|
||||
error: {
|
||||
message: '资产工具接口当前未启用。',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
router.use(
|
||||
QWEN_SPRITE_MASTER_GENERATE_PATH,
|
||||
toExpressHandler((request, response) =>
|
||||
handleGenerateMaster(config, request, response),
|
||||
),
|
||||
);
|
||||
router.use(
|
||||
QWEN_SPRITE_SHEET_GENERATE_PATH,
|
||||
toExpressHandler((request, response) =>
|
||||
handleGenerateSheet(config, request, response),
|
||||
),
|
||||
);
|
||||
router.use(
|
||||
QWEN_SPRITE_FRAME_REPAIR_PATH,
|
||||
toExpressHandler((request, response) =>
|
||||
handleRepairFrame(config, request, response),
|
||||
),
|
||||
);
|
||||
router.use(
|
||||
QWEN_SPRITE_SAVE_PATH,
|
||||
toExpressHandler((request, response) =>
|
||||
handleSaveAsset(config.projectRoot, request, response),
|
||||
),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
272
server-node/src/modules/combat/combatResolutionService.ts
Normal file
272
server-node/src/modules/combat/combatResolutionService.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import type {
|
||||
RuntimeBattlePresentation,
|
||||
RuntimeStoryPatch,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import { conflict } from '../../errors.js';
|
||||
import {
|
||||
getEncounterNpcState,
|
||||
setEncounterNpcState,
|
||||
type RuntimeSession,
|
||||
} from '../story/runtimeSession.js';
|
||||
|
||||
type CombatActionConfig = {
|
||||
actionText: string;
|
||||
manaCost: number;
|
||||
baseDamage: number;
|
||||
counterMultiplier: number;
|
||||
heal?: number;
|
||||
manaRestore?: number;
|
||||
};
|
||||
|
||||
export type CombatResolution = {
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
battle: RuntimeBattlePresentation;
|
||||
patches: RuntimeStoryPatch[];
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
function getAliveTarget(session: RuntimeSession) {
|
||||
return session.sceneHostileNpcs.find((npc) => npc.hp > 0) ?? null;
|
||||
}
|
||||
|
||||
function applySparAffinityReward(session: RuntimeSession) {
|
||||
const npcState = getEncounterNpcState(session);
|
||||
const encounter = session.currentEncounter;
|
||||
if (!npcState || !encounter || encounter.kind !== 'npc') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextAffinity = npcState.affinity + 3;
|
||||
setEncounterNpcState(session, {
|
||||
...npcState,
|
||||
affinity: nextAffinity,
|
||||
});
|
||||
|
||||
return {
|
||||
npcId: encounter.id,
|
||||
previousAffinity: npcState.affinity,
|
||||
nextAffinity,
|
||||
} satisfies Extract<RuntimeStoryPatch, { type: 'npc_affinity_changed' }>;
|
||||
}
|
||||
|
||||
function clampPlayerVitals(session: RuntimeSession) {
|
||||
session.playerHp = Math.max(0, Math.min(session.playerHp, session.playerMaxHp));
|
||||
session.playerMana = Math.max(
|
||||
0,
|
||||
Math.min(session.playerMana, session.playerMaxMana),
|
||||
);
|
||||
}
|
||||
|
||||
function finishBattle(
|
||||
session: RuntimeSession,
|
||||
outcome: RuntimeBattlePresentation['outcome'],
|
||||
) {
|
||||
session.inBattle = false;
|
||||
session.sceneHostileNpcs = [];
|
||||
session.currentNpcBattleMode = null;
|
||||
session.currentNpcBattleOutcome =
|
||||
outcome === 'spar_complete'
|
||||
? 'spar_complete'
|
||||
: outcome === 'victory'
|
||||
? 'fight_victory'
|
||||
: null;
|
||||
|
||||
if (outcome === 'victory' || outcome === 'escaped') {
|
||||
session.currentEncounter = null;
|
||||
session.npcInteractionActive = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.currentEncounter?.kind === 'npc') {
|
||||
session.npcInteractionActive = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCombatAction(
|
||||
session: RuntimeSession,
|
||||
functionId: string,
|
||||
): CombatResolution {
|
||||
const target = getAliveTarget(session);
|
||||
if (!session.inBattle || !target) {
|
||||
throw conflict('当前不在可结算战斗态,不能执行该战斗动作');
|
||||
}
|
||||
|
||||
if (functionId === 'battle_escape_breakout') {
|
||||
finishBattle(session, 'escaped');
|
||||
return {
|
||||
actionText: '强行脱离战斗',
|
||||
resultText: `你抓住空当摆脱了${target.name}的压制,先把这轮正面冲突拉开了。`,
|
||||
battle: {
|
||||
targetId: target.id,
|
||||
targetName: target.name,
|
||||
outcome: 'escaped',
|
||||
},
|
||||
patches: [
|
||||
{
|
||||
type: 'battle_resolved',
|
||||
functionId,
|
||||
targetId: target.id,
|
||||
outcome: 'escaped',
|
||||
},
|
||||
{
|
||||
type: 'status_changed',
|
||||
inBattle: session.inBattle,
|
||||
npcInteractionActive: session.npcInteractionActive,
|
||||
currentNpcBattleMode: session.currentNpcBattleMode,
|
||||
currentNpcBattleOutcome: session.currentNpcBattleOutcome,
|
||||
},
|
||||
{
|
||||
type: 'encounter_changed',
|
||||
encounterId: session.currentEncounter?.id ?? null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const action = COMBAT_ACTIONS[functionId];
|
||||
if (!action) {
|
||||
throw conflict(`暂不支持的战斗动作:${functionId}`);
|
||||
}
|
||||
|
||||
if (action.manaCost > session.playerMana) {
|
||||
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;
|
||||
|
||||
session.playerMana -= action.manaCost;
|
||||
session.playerHp += action.heal ?? 0;
|
||||
session.playerMana += action.manaRestore ?? 0;
|
||||
clampPlayerVitals(session);
|
||||
|
||||
target.hp = Math.max(isSpar ? 1 : 0, target.hp - damageDealt);
|
||||
|
||||
const patches: RuntimeStoryPatch[] = [];
|
||||
let resultText = '';
|
||||
let outcome: RuntimeBattlePresentation['outcome'] = 'ongoing';
|
||||
let damageTaken = 0;
|
||||
|
||||
if ((isSpar && target.hp <= 1) || (!isSpar && target.hp <= 0)) {
|
||||
if (isSpar) {
|
||||
const affinityPatch = applySparAffinityReward(session);
|
||||
finishBattle(session, 'spar_complete');
|
||||
if (affinityPatch) {
|
||||
patches.push(affinityPatch);
|
||||
}
|
||||
outcome = 'spar_complete';
|
||||
resultText = `你和${target.name}这轮过招已经分出高下,对方也承认了你的身手。`;
|
||||
} else {
|
||||
finishBattle(session, 'victory');
|
||||
outcome = 'victory';
|
||||
resultText = `你彻底压垮了${target.name}的节奏,这场战斗已经收口。`;
|
||||
}
|
||||
} else {
|
||||
const baseCounter = isSpar
|
||||
? 1
|
||||
: Math.max(4, Math.round(target.maxHp * 0.16 * action.counterMultiplier));
|
||||
damageTaken = baseCounter;
|
||||
session.playerHp = Math.max(isSpar ? 1 : 0, session.playerHp - damageTaken);
|
||||
|
||||
if (isSpar && session.playerHp <= 1) {
|
||||
const affinityPatch = applySparAffinityReward(session);
|
||||
finishBattle(session, 'spar_complete');
|
||||
if (affinityPatch) {
|
||||
patches.push(affinityPatch);
|
||||
}
|
||||
outcome = 'spar_complete';
|
||||
resultText = `${target.name}也逼到了你的极限,这场切磋点到为止,双方都默认收手。`;
|
||||
} else if (!isSpar && session.playerHp <= 0) {
|
||||
session.playerHp = 0;
|
||||
session.inBattle = false;
|
||||
session.sceneHostileNpcs = [];
|
||||
session.currentNpcBattleMode = null;
|
||||
session.npcInteractionActive = false;
|
||||
session.currentEncounter = null;
|
||||
outcome = 'escaped';
|
||||
resultText = `你在和${target.name}的交锋里被压到失去战斗能力,这轮正面冲突只能先断开。`;
|
||||
} else {
|
||||
resultText = `${action.actionText}命中了${target.name},但对方仍然顶住并回敬了一轮压力。`;
|
||||
}
|
||||
}
|
||||
|
||||
patches.push(
|
||||
{
|
||||
type: 'battle_resolved',
|
||||
functionId,
|
||||
targetId: target.id,
|
||||
damageDealt,
|
||||
damageTaken,
|
||||
outcome,
|
||||
},
|
||||
{
|
||||
type: 'status_changed',
|
||||
inBattle: session.inBattle,
|
||||
npcInteractionActive: session.npcInteractionActive,
|
||||
currentNpcBattleMode: session.currentNpcBattleMode,
|
||||
currentNpcBattleOutcome: session.currentNpcBattleOutcome,
|
||||
},
|
||||
{
|
||||
type: 'encounter_changed',
|
||||
encounterId: session.currentEncounter?.id ?? null,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
actionText: action.actionText,
|
||||
resultText,
|
||||
battle: {
|
||||
targetId: target.id,
|
||||
targetName: target.name,
|
||||
damageDealt,
|
||||
damageTaken,
|
||||
outcome,
|
||||
},
|
||||
patches,
|
||||
};
|
||||
}
|
||||
141
server-node/src/modules/editor/editorRoutes.ts
Normal file
141
server-node/src/modules/editor/editorRoutes.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Router } from 'express';
|
||||
|
||||
import type { AppConfig } from '../../config.js';
|
||||
import { badRequest, notFound } from '../../errors.js';
|
||||
import { asyncHandler } from '../../http.js';
|
||||
|
||||
const EDITOR_JSON_RESOURCE_FILES = {
|
||||
'item-overrides': 'src/data/itemOverrides.json',
|
||||
'npc-visual-overrides': 'src/data/npcVisualOverrides.json',
|
||||
'npc-layout-config': 'src/data/npcLayoutConfig.json',
|
||||
'character-overrides': 'src/data/characterOverrides.json',
|
||||
'monster-overrides': 'src/data/monsterOverrides.json',
|
||||
'scene-overrides': 'src/data/sceneOverrides.json',
|
||||
'scene-npc-overrides': 'src/data/sceneNpcOverrides.json',
|
||||
'state-function-overrides': 'src/data/stateFunctionOverrides.json',
|
||||
} as const;
|
||||
|
||||
type EditorJsonResourceId = keyof typeof EDITOR_JSON_RESOURCE_FILES;
|
||||
|
||||
function isEditorJsonPayload(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function resolveEditorJsonFile(
|
||||
config: AppConfig,
|
||||
resourceId: string,
|
||||
) {
|
||||
const relativePath =
|
||||
EDITOR_JSON_RESOURCE_FILES[
|
||||
resourceId as EditorJsonResourceId
|
||||
];
|
||||
if (!relativePath) {
|
||||
throw notFound('未知的编辑器资源。');
|
||||
}
|
||||
|
||||
return path.resolve(config.projectRoot, relativePath);
|
||||
}
|
||||
|
||||
async function readEditorJsonFile(filePath: string) {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
return JSON.parse(content) as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectPngAssetPaths(
|
||||
rootDir: string,
|
||||
relativeDir = 'Icons',
|
||||
): Promise<string[]> {
|
||||
const entries = await readdir(rootDir, { withFileTypes: true });
|
||||
const collected: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(rootDir, entry.name);
|
||||
const relativePath = `${relativeDir}/${entry.name}`.replace(/\\/g, '/');
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
collected.push(
|
||||
...(await collectPngAssetPaths(absolutePath, relativePath)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name.toLowerCase().endsWith('.png')) {
|
||||
collected.push(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
return collected.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function createEditorRoutes(config: AppConfig) {
|
||||
const router = Router();
|
||||
|
||||
router.use((request, response, next) => {
|
||||
if (
|
||||
request.path !== '/api/editor' &&
|
||||
!request.path.startsWith('/api/editor/')
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.editorApiEnabled) {
|
||||
response.status(403).json({
|
||||
error: {
|
||||
message: '编辑器接口当前未启用。',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/api/editor/catalog/items',
|
||||
asyncHandler(async (_request, response) => {
|
||||
response.json({
|
||||
assetPaths: await collectPngAssetPaths(
|
||||
path.resolve(config.projectRoot, 'public/Icons'),
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/api/editor/json/:resourceId',
|
||||
asyncHandler(async (request, response) => {
|
||||
const filePath = resolveEditorJsonFile(config, request.params.resourceId);
|
||||
response.json(await readEditorJsonFile(filePath));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/api/editor/json/:resourceId',
|
||||
asyncHandler(async (request, response) => {
|
||||
if (!isEditorJsonPayload(request.body)) {
|
||||
throw badRequest('编辑器保存请求必须是 JSON 对象。');
|
||||
}
|
||||
|
||||
const filePath = resolveEditorJsonFile(config, request.params.resourceId);
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify(request.body, null, 2) + '\n',
|
||||
'utf8',
|
||||
);
|
||||
response.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
1
server-node/src/modules/inventory/index.ts
Normal file
1
server-node/src/modules/inventory/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './inventoryMutationService.js';
|
||||
@@ -0,0 +1,230 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js';
|
||||
import {
|
||||
craftForgeRecipe,
|
||||
equipInventoryItem,
|
||||
useInventoryItem,
|
||||
type RuntimeGameState,
|
||||
type RuntimeInventoryItem,
|
||||
} from './inventoryMutationService.js';
|
||||
|
||||
const TEST_WORLD = 'WUXIA' as RuntimeGameState['worldType'];
|
||||
const TEST_IDLE_ANIMATION = 'idle' as RuntimeGameState['animationState'];
|
||||
|
||||
function requireCharacter() {
|
||||
return createTestPlayerCharacter<
|
||||
NonNullable<RuntimeGameState['playerCharacter']>
|
||||
>();
|
||||
}
|
||||
|
||||
function buildItem(
|
||||
overrides: Partial<RuntimeInventoryItem> &
|
||||
Pick<RuntimeInventoryItem, 'id' | 'category' | 'name'>,
|
||||
): RuntimeInventoryItem {
|
||||
return {
|
||||
quantity: 1,
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createState(overrides: Partial<RuntimeGameState> = {}): RuntimeGameState {
|
||||
return {
|
||||
worldType: TEST_WORLD,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: requireCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'test-scene',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: TEST_IDLE_ANIMATION,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'melee',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 64,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 18,
|
||||
playerMaxMana: 60,
|
||||
playerSkillCooldowns: {
|
||||
slash: 2,
|
||||
},
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 120,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
...overrides,
|
||||
} satisfies RuntimeGameState;
|
||||
}
|
||||
|
||||
test('useInventoryItem applies recovery, cooldown推进 and buff mutation', () => {
|
||||
const state = createState({
|
||||
playerInventory: [
|
||||
buildItem({
|
||||
id: 'focus-tonic',
|
||||
category: '消耗品',
|
||||
name: '凝神灵液',
|
||||
rarity: 'rare',
|
||||
tags: ['healing', 'mana'],
|
||||
useProfile: {
|
||||
hpRestore: 22,
|
||||
manaRestore: 16,
|
||||
cooldownReduction: 1,
|
||||
buildBuffs: [
|
||||
{
|
||||
id: 'focus-tonic:buff',
|
||||
sourceType: 'item',
|
||||
sourceId: 'focus-tonic',
|
||||
name: '凝神增益',
|
||||
tags: ['快剑'],
|
||||
durationTurns: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const result = useInventoryItem(state, 'focus-tonic');
|
||||
assert.equal(result.ok, true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
assert.equal(result.mutation, 'use');
|
||||
assert.equal(result.nextState.playerHp, 86);
|
||||
assert.equal(result.nextState.playerMana, 34);
|
||||
assert.equal(result.nextState.playerSkillCooldowns.slash, 1);
|
||||
assert.equal(result.nextState.playerInventory.length, 0);
|
||||
assert.equal(result.nextState.runtimeStats.itemsUsed, 1);
|
||||
assert.equal(result.nextState.activeBuildBuffs[0]?.id, 'focus-tonic:buff');
|
||||
});
|
||||
|
||||
test('equipInventoryItem swaps loadout and returns replaced gear to inventory', () => {
|
||||
const oldWeapon = buildItem({
|
||||
id: 'starter-blade',
|
||||
category: '武器',
|
||||
name: '旧佩剑',
|
||||
rarity: 'common',
|
||||
tags: ['weapon', '快剑'],
|
||||
equipmentSlotId: 'weapon',
|
||||
statProfile: {
|
||||
outgoingDamageBonus: 0.04,
|
||||
},
|
||||
buildProfile: {
|
||||
role: '快剑',
|
||||
tags: ['快剑'],
|
||||
synergy: ['快剑'],
|
||||
forgeRank: 0,
|
||||
},
|
||||
});
|
||||
const nextWeapon = buildItem({
|
||||
id: 'storm-blade',
|
||||
category: '武器',
|
||||
name: '逐风短剑',
|
||||
rarity: 'rare',
|
||||
tags: ['weapon', '快剑', '突进'],
|
||||
equipmentSlotId: 'weapon',
|
||||
statProfile: {
|
||||
outgoingDamageBonus: 0.12,
|
||||
},
|
||||
buildProfile: {
|
||||
role: '快剑',
|
||||
tags: ['快剑', '突进'],
|
||||
synergy: ['快剑', '突进'],
|
||||
forgeRank: 0,
|
||||
},
|
||||
});
|
||||
const state = createState({
|
||||
playerInventory: [nextWeapon],
|
||||
playerEquipment: {
|
||||
weapon: oldWeapon,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = equipInventoryItem(state, 'storm-blade');
|
||||
assert.equal(result.ok, true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
assert.equal(result.mutation, 'equip');
|
||||
assert.equal(result.slot, 'weapon');
|
||||
assert.equal(result.nextState.playerEquipment.weapon?.name, '逐风短剑');
|
||||
assert.equal(
|
||||
result.nextState.playerInventory.some((item) => item.id === 'starter-blade'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
result.nextState.playerInventory.some((item) => item.id === 'storm-blade'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('craftForgeRecipe consumes materials and produces forged output on the server side', () => {
|
||||
const state = createState({
|
||||
playerCurrency: 40,
|
||||
playerInventory: [
|
||||
buildItem({
|
||||
id: 'scrap-iron',
|
||||
category: '材料',
|
||||
name: '残铁碎片',
|
||||
quantity: 3,
|
||||
rarity: 'common',
|
||||
tags: ['material'],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const result = craftForgeRecipe(state, 'synthesis-refined-ingot');
|
||||
assert.equal(result.ok, true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
assert.equal(result.mutation, 'craft');
|
||||
assert.equal(result.nextState.playerCurrency, 22);
|
||||
assert.equal(result.createdItem?.name, '精炼锭材');
|
||||
assert.equal(
|
||||
result.nextState.playerInventory.some((item) => item.name === '精炼锭材'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
result.nextState.playerInventory.some((item) => item.id === 'scrap-iron'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
458
server-node/src/modules/inventory/inventoryMutationService.ts
Normal file
458
server-node/src/modules/inventory/inventoryMutationService.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
import {
|
||||
addInventoryItems,
|
||||
appendBuildBuffs,
|
||||
applyEquipmentLoadoutToState,
|
||||
buildForgeSuccessText,
|
||||
buildInventoryUseResultText,
|
||||
executeDismantleItem,
|
||||
executeForgeRecipe,
|
||||
executeReforgeItem,
|
||||
getEquipmentSlotFromItem,
|
||||
getEquipmentSlotLabel,
|
||||
getForgeRecipeViews,
|
||||
getReforgeCostView,
|
||||
incrementGameRuntimeStats,
|
||||
isInventoryItemUsable,
|
||||
removeInventoryItem,
|
||||
resolveInventoryItemUseEffect,
|
||||
} from '../../bridges/legacyInventoryRuntimeBridge.js';
|
||||
|
||||
export type RuntimeGameState = Parameters<
|
||||
typeof applyEquipmentLoadoutToState
|
||||
>[0];
|
||||
export type RuntimeInventoryItem = Parameters<
|
||||
typeof getEquipmentSlotFromItem
|
||||
>[0];
|
||||
export type RuntimeEquipmentSlotId = Exclude<
|
||||
ReturnType<typeof getEquipmentSlotFromItem>,
|
||||
null
|
||||
>;
|
||||
export type RuntimeInventoryUseEffect = Exclude<
|
||||
ReturnType<typeof resolveInventoryItemUseEffect>,
|
||||
null
|
||||
>;
|
||||
export type RuntimeForgeRecipeView = ReturnType<
|
||||
typeof getForgeRecipeViews
|
||||
>[number];
|
||||
export type RuntimeReforgeCostView = ReturnType<typeof getReforgeCostView>;
|
||||
|
||||
type InventoryMutationKind =
|
||||
| 'use'
|
||||
| 'equip'
|
||||
| 'unequip'
|
||||
| 'craft'
|
||||
| 'dismantle'
|
||||
| 'reforge';
|
||||
|
||||
type InventoryMutationFailureCode =
|
||||
| 'missing_player_character'
|
||||
| 'battle_locked'
|
||||
| 'item_not_found'
|
||||
| 'item_not_usable'
|
||||
| 'item_not_equippable'
|
||||
| 'slot_empty'
|
||||
| 'recipe_not_available'
|
||||
| 'mutation_not_available';
|
||||
|
||||
export type InventoryMutationFailure = {
|
||||
ok: false;
|
||||
code: InventoryMutationFailureCode;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type InventoryMutationSuccess = {
|
||||
ok: true;
|
||||
mutation: InventoryMutationKind;
|
||||
nextState: RuntimeGameState;
|
||||
actionText: string;
|
||||
detailText: string;
|
||||
item?: RuntimeInventoryItem;
|
||||
slot?: RuntimeEquipmentSlotId;
|
||||
replacedItem?: RuntimeInventoryItem | null;
|
||||
createdItem?: RuntimeInventoryItem | null;
|
||||
outputs?: RuntimeInventoryItem[];
|
||||
effect?: RuntimeInventoryUseEffect;
|
||||
reforgeCost?: RuntimeReforgeCostView;
|
||||
};
|
||||
|
||||
export type InventoryMutationResult =
|
||||
| InventoryMutationFailure
|
||||
| InventoryMutationSuccess;
|
||||
|
||||
function createFailure(
|
||||
code: InventoryMutationFailureCode,
|
||||
message: string,
|
||||
): InventoryMutationFailure {
|
||||
return {
|
||||
ok: false,
|
||||
code,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function tickCooldownMap(
|
||||
cooldowns: RuntimeGameState['playerSkillCooldowns'],
|
||||
turns: number,
|
||||
) {
|
||||
let nextCooldowns = cooldowns;
|
||||
const totalTurns = Math.max(0, Math.floor(turns));
|
||||
|
||||
for (let index = 0; index < totalTurns; index += 1) {
|
||||
nextCooldowns = Object.fromEntries(
|
||||
Object.entries(nextCooldowns).map(([skillId, value]) => [
|
||||
skillId,
|
||||
Math.max(0, Math.floor(value) - 1),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return nextCooldowns;
|
||||
}
|
||||
|
||||
function normalizeEquippedItem(item: RuntimeInventoryItem): RuntimeInventoryItem {
|
||||
return {
|
||||
...item,
|
||||
quantity: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEquipResultText(
|
||||
item: RuntimeInventoryItem,
|
||||
slot: RuntimeEquipmentSlotId,
|
||||
replacedItem?: RuntimeInventoryItem | null,
|
||||
) {
|
||||
return replacedItem
|
||||
? `你将${replacedItem.name}从${getEquipmentSlotLabel(slot)}位上换下,改为装备${item.name}。`
|
||||
: `你将${item.name}装备在${getEquipmentSlotLabel(slot)}位上。`;
|
||||
}
|
||||
|
||||
function buildUnequipResultText(item: RuntimeInventoryItem) {
|
||||
return `你卸下了${item.name},暂时收回背包。`;
|
||||
}
|
||||
|
||||
export function getForgeRecipeCatalog(
|
||||
state: RuntimeGameState,
|
||||
): RuntimeForgeRecipeView[] {
|
||||
return getForgeRecipeViews(
|
||||
state.playerInventory,
|
||||
state.playerCurrency,
|
||||
state.worldType,
|
||||
);
|
||||
}
|
||||
|
||||
export function useInventoryItem(
|
||||
state: RuntimeGameState,
|
||||
itemId: string,
|
||||
): InventoryMutationResult {
|
||||
const playerCharacter = state.playerCharacter;
|
||||
if (!playerCharacter) {
|
||||
return createFailure(
|
||||
'missing_player_character',
|
||||
'缺少玩家角色,无法使用背包物品。',
|
||||
);
|
||||
}
|
||||
|
||||
const item = state.playerInventory.find((candidate) => candidate.id === itemId);
|
||||
if (!item || item.quantity <= 0) {
|
||||
return createFailure('item_not_found', '未找到可使用的背包物品。');
|
||||
}
|
||||
|
||||
if (!isInventoryItemUsable(item)) {
|
||||
return createFailure('item_not_usable', `${item.name} 当前不可直接使用。`);
|
||||
}
|
||||
|
||||
const effect = resolveInventoryItemUseEffect(item, playerCharacter);
|
||||
if (
|
||||
!effect ||
|
||||
(effect.hpRestore ?? 0) <= 0 &&
|
||||
(effect.manaRestore ?? 0) <= 0 &&
|
||||
(effect.cooldownReduction ?? 0) <= 0 &&
|
||||
(effect.buildBuffs?.length ?? 0) <= 0
|
||||
) {
|
||||
return createFailure(
|
||||
'item_not_usable',
|
||||
`${item.name} 当前没有可结算的使用效果。`,
|
||||
);
|
||||
}
|
||||
|
||||
const nextState = {
|
||||
...state,
|
||||
playerHp: Math.min(state.playerMaxHp, state.playerHp + effect.hpRestore),
|
||||
playerMana: Math.min(
|
||||
state.playerMaxMana,
|
||||
state.playerMana + effect.manaRestore,
|
||||
),
|
||||
playerSkillCooldowns: tickCooldownMap(
|
||||
state.playerSkillCooldowns,
|
||||
effect.cooldownReduction,
|
||||
),
|
||||
activeBuildBuffs: appendBuildBuffs(
|
||||
state.activeBuildBuffs,
|
||||
effect.buildBuffs,
|
||||
),
|
||||
playerInventory: removeInventoryItem(state.playerInventory, item.id, 1),
|
||||
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, {
|
||||
itemsUsed: 1,
|
||||
}),
|
||||
} satisfies RuntimeGameState;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
mutation: 'use',
|
||||
nextState,
|
||||
actionText: `使用${item.name}`,
|
||||
detailText: buildInventoryUseResultText(item, effect),
|
||||
item,
|
||||
effect,
|
||||
};
|
||||
}
|
||||
|
||||
export function equipInventoryItem(
|
||||
state: RuntimeGameState,
|
||||
itemId: string,
|
||||
): InventoryMutationResult {
|
||||
if (!state.playerCharacter) {
|
||||
return createFailure(
|
||||
'missing_player_character',
|
||||
'缺少玩家角色,无法调整装备。',
|
||||
);
|
||||
}
|
||||
|
||||
if (state.inBattle) {
|
||||
return createFailure('battle_locked', '战斗中无法调整装备。');
|
||||
}
|
||||
|
||||
const item = state.playerInventory.find((candidate) => candidate.id === itemId);
|
||||
if (!item || item.quantity <= 0) {
|
||||
return createFailure('item_not_found', '背包里没有这件装备。');
|
||||
}
|
||||
|
||||
const slot = getEquipmentSlotFromItem(item);
|
||||
if (!slot) {
|
||||
return createFailure('item_not_equippable', `${item.name} 不是可装备物品。`);
|
||||
}
|
||||
|
||||
const replacedItem = state.playerEquipment[slot];
|
||||
const nextEquipment = {
|
||||
...state.playerEquipment,
|
||||
[slot]: normalizeEquippedItem(item),
|
||||
};
|
||||
|
||||
let nextInventory = removeInventoryItem(state.playerInventory, item.id, 1);
|
||||
if (replacedItem) {
|
||||
nextInventory = addInventoryItems(nextInventory, [replacedItem]);
|
||||
}
|
||||
|
||||
const nextState = applyEquipmentLoadoutToState(
|
||||
{
|
||||
...state,
|
||||
playerInventory: nextInventory,
|
||||
},
|
||||
nextEquipment,
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
mutation: 'equip',
|
||||
nextState,
|
||||
actionText: `装备${item.name}`,
|
||||
detailText: buildEquipResultText(item, slot, replacedItem),
|
||||
item,
|
||||
slot,
|
||||
replacedItem,
|
||||
};
|
||||
}
|
||||
|
||||
export function unequipInventoryItem(
|
||||
state: RuntimeGameState,
|
||||
slot: RuntimeEquipmentSlotId,
|
||||
): InventoryMutationResult {
|
||||
if (!state.playerCharacter) {
|
||||
return createFailure(
|
||||
'missing_player_character',
|
||||
'缺少玩家角色,无法卸下装备。',
|
||||
);
|
||||
}
|
||||
|
||||
if (state.inBattle) {
|
||||
return createFailure('battle_locked', '战斗中无法卸下装备。');
|
||||
}
|
||||
|
||||
const equippedItem = state.playerEquipment[slot];
|
||||
if (!equippedItem) {
|
||||
return createFailure('slot_empty', `${getEquipmentSlotLabel(slot)}位当前没有装备。`);
|
||||
}
|
||||
|
||||
const nextEquipment = {
|
||||
...state.playerEquipment,
|
||||
[slot]: null,
|
||||
};
|
||||
const nextState = applyEquipmentLoadoutToState(
|
||||
{
|
||||
...state,
|
||||
playerInventory: addInventoryItems(state.playerInventory, [equippedItem]),
|
||||
},
|
||||
nextEquipment,
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
mutation: 'unequip',
|
||||
nextState,
|
||||
actionText: `卸下${equippedItem.name}`,
|
||||
detailText: buildUnequipResultText(equippedItem),
|
||||
item: equippedItem,
|
||||
slot,
|
||||
};
|
||||
}
|
||||
|
||||
export function craftForgeRecipe(
|
||||
state: RuntimeGameState,
|
||||
recipeId: string,
|
||||
): InventoryMutationResult {
|
||||
if (!state.playerCharacter) {
|
||||
return createFailure(
|
||||
'missing_player_character',
|
||||
'缺少玩家角色,无法执行锻造配方。',
|
||||
);
|
||||
}
|
||||
|
||||
if (state.inBattle) {
|
||||
return createFailure('battle_locked', '战斗中无法使用工坊。');
|
||||
}
|
||||
|
||||
const recipe = getForgeRecipeCatalog(state).find(
|
||||
(candidate) => candidate.id === recipeId,
|
||||
);
|
||||
if (!recipe) {
|
||||
return createFailure('recipe_not_available', '未找到目标锻造配方。');
|
||||
}
|
||||
|
||||
const result = executeForgeRecipe(
|
||||
state.playerInventory,
|
||||
recipeId,
|
||||
state.worldType,
|
||||
state.playerCurrency,
|
||||
);
|
||||
if (!result) {
|
||||
return createFailure(
|
||||
'mutation_not_available',
|
||||
`${recipe.name} 当前材料或货币不足。`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
mutation: 'craft',
|
||||
nextState: {
|
||||
...state,
|
||||
playerCurrency: result.currency,
|
||||
playerInventory: result.inventory,
|
||||
},
|
||||
actionText: `制作${result.createdItem.name}`,
|
||||
detailText: buildForgeSuccessText('craft', {
|
||||
recipeName: recipe.name,
|
||||
createdItemName: result.createdItem.name,
|
||||
currencyText: recipe.currencyText,
|
||||
}),
|
||||
createdItem: result.createdItem,
|
||||
};
|
||||
}
|
||||
|
||||
export function dismantleInventoryItem(
|
||||
state: RuntimeGameState,
|
||||
itemId: string,
|
||||
): InventoryMutationResult {
|
||||
if (!state.playerCharacter) {
|
||||
return createFailure(
|
||||
'missing_player_character',
|
||||
'缺少玩家角色,无法执行拆解。',
|
||||
);
|
||||
}
|
||||
|
||||
if (state.inBattle) {
|
||||
return createFailure('battle_locked', '战斗中无法执行拆解。');
|
||||
}
|
||||
|
||||
const item = state.playerInventory.find((candidate) => candidate.id === itemId);
|
||||
if (!item || item.quantity <= 0) {
|
||||
return createFailure('item_not_found', '未找到可拆解的物品。');
|
||||
}
|
||||
|
||||
const result = executeDismantleItem(state.playerInventory, itemId);
|
||||
if (!result) {
|
||||
return createFailure(
|
||||
'mutation_not_available',
|
||||
`${item.name} 当前不支持拆解。`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
mutation: 'dismantle',
|
||||
nextState: {
|
||||
...state,
|
||||
playerInventory: result.inventory,
|
||||
},
|
||||
actionText: `拆解${item.name}`,
|
||||
detailText: buildForgeSuccessText('dismantle', {
|
||||
sourceItemName: item.name,
|
||||
outputNames: result.outputs.map((output) => output.name),
|
||||
}),
|
||||
item,
|
||||
outputs: result.outputs,
|
||||
};
|
||||
}
|
||||
|
||||
export function reforgeInventoryItem(
|
||||
state: RuntimeGameState,
|
||||
itemId: string,
|
||||
): InventoryMutationResult {
|
||||
if (!state.playerCharacter) {
|
||||
return createFailure(
|
||||
'missing_player_character',
|
||||
'缺少玩家角色,无法执行重铸。',
|
||||
);
|
||||
}
|
||||
|
||||
if (state.inBattle) {
|
||||
return createFailure('battle_locked', '战斗中无法执行重铸。');
|
||||
}
|
||||
|
||||
const item = state.playerInventory.find((candidate) => candidate.id === itemId);
|
||||
if (!item || item.quantity <= 0) {
|
||||
return createFailure('item_not_found', '未找到可重铸的物品。');
|
||||
}
|
||||
|
||||
const reforgeCost = getReforgeCostView(item, state.worldType);
|
||||
const result = executeReforgeItem(
|
||||
state.playerInventory,
|
||||
itemId,
|
||||
state.playerCurrency,
|
||||
);
|
||||
if (!result) {
|
||||
return createFailure(
|
||||
'mutation_not_available',
|
||||
`${item.name} 当前不满足重铸条件。`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
mutation: 'reforge',
|
||||
nextState: {
|
||||
...state,
|
||||
playerCurrency: Math.max(0, state.playerCurrency - result.currencyCost),
|
||||
playerInventory: result.inventory,
|
||||
},
|
||||
actionText: `重铸${item.name}`,
|
||||
detailText: buildForgeSuccessText('reforge', {
|
||||
sourceItemName: item.name,
|
||||
createdItemName: result.reforgedItem.name,
|
||||
currencyText: reforgeCost.currencyText,
|
||||
}),
|
||||
item,
|
||||
createdItem: result.reforgedItem,
|
||||
reforgeCost,
|
||||
};
|
||||
}
|
||||
197
server-node/src/modules/inventory/inventoryStoryActionService.ts
Normal file
197
server-node/src/modules/inventory/inventoryStoryActionService.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type {
|
||||
RuntimeStoryActionRequest,
|
||||
RuntimeStoryPatch,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import { conflict, invalidRequest } from '../../errors.js';
|
||||
import {
|
||||
calculatePlayerBuildSnapshot,
|
||||
type RuntimeGameState as BuildRuntimeGameState,
|
||||
} from '../build/buildCalculationService.js';
|
||||
import {
|
||||
craftForgeRecipe,
|
||||
dismantleInventoryItem,
|
||||
equipInventoryItem,
|
||||
reforgeInventoryItem,
|
||||
unequipInventoryItem,
|
||||
useInventoryItem,
|
||||
type InventoryMutationFailure,
|
||||
type InventoryMutationSuccess,
|
||||
type RuntimeGameState as InventoryRuntimeGameState,
|
||||
} from './inventoryMutationService.js';
|
||||
import {
|
||||
replaceRuntimeSessionRawGameState,
|
||||
type RuntimeSession,
|
||||
} from '../story/runtimeSession.js';
|
||||
|
||||
const SUPPORTED_INVENTORY_STORY_FUNCTION_IDS = new Set<string>([
|
||||
'equipment_equip',
|
||||
'equipment_unequip',
|
||||
'forge_craft',
|
||||
'forge_dismantle',
|
||||
'forge_reforge',
|
||||
'inventory_use',
|
||||
]);
|
||||
|
||||
type InventoryStoryResolution = {
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
patches: RuntimeStoryPatch[];
|
||||
toast?: string | null;
|
||||
};
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function isObject(value: unknown): value is JsonRecord {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readPayload(request: RuntimeStoryActionRequest) {
|
||||
return isObject(request.action.payload) ? request.action.payload : {};
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
function readItemId(request: RuntimeStoryActionRequest) {
|
||||
const payload = readPayload(request);
|
||||
return (
|
||||
readString(payload.itemId) ||
|
||||
readString(payload.targetId) ||
|
||||
readString(request.action.targetId)
|
||||
);
|
||||
}
|
||||
|
||||
function readRecipeId(request: RuntimeStoryActionRequest) {
|
||||
const payload = readPayload(request);
|
||||
return (
|
||||
readString(payload.recipeId) ||
|
||||
readString(payload.targetId) ||
|
||||
readString(request.action.targetId)
|
||||
);
|
||||
}
|
||||
|
||||
function readEquipmentSlotId(request: RuntimeStoryActionRequest) {
|
||||
const payload = readPayload(request);
|
||||
const slotId =
|
||||
readString(payload.slotId) || readString(request.action.targetId);
|
||||
|
||||
if (slotId === 'weapon' || slotId === 'armor' || slotId === 'relic') {
|
||||
return slotId;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function refreshSessionFromGameState(
|
||||
session: RuntimeSession,
|
||||
nextGameState: InventoryMutationSuccess['nextState'],
|
||||
) {
|
||||
replaceRuntimeSessionRawGameState(
|
||||
session,
|
||||
nextGameState as unknown as JsonRecord,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildBuildToast(
|
||||
nextState: InventoryMutationSuccess['nextState'],
|
||||
) {
|
||||
const snapshot = calculatePlayerBuildSnapshot(
|
||||
nextState as BuildRuntimeGameState,
|
||||
);
|
||||
if (!snapshot.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buildMultiplier =
|
||||
snapshot.value.buildBreakdown.buildDamageMultiplier.toFixed(2);
|
||||
return `当前 Build 倍率 x${buildMultiplier}`;
|
||||
}
|
||||
|
||||
function throwMutationFailure(error: InventoryMutationFailure): never {
|
||||
switch (error.code) {
|
||||
case 'item_not_equippable':
|
||||
case 'recipe_not_available':
|
||||
throw invalidRequest(error.message);
|
||||
default:
|
||||
throw conflict(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMutation(
|
||||
request: RuntimeStoryActionRequest,
|
||||
state: InventoryRuntimeGameState,
|
||||
) {
|
||||
switch (request.action.functionId) {
|
||||
case 'inventory_use': {
|
||||
const itemId = readItemId(request);
|
||||
if (!itemId) {
|
||||
throw invalidRequest('inventory_use 缺少 itemId');
|
||||
}
|
||||
return useInventoryItem(state, itemId);
|
||||
}
|
||||
case 'equipment_equip': {
|
||||
const itemId = readItemId(request);
|
||||
if (!itemId) {
|
||||
throw invalidRequest('equipment_equip 缺少 itemId');
|
||||
}
|
||||
return equipInventoryItem(state, itemId);
|
||||
}
|
||||
case 'equipment_unequip': {
|
||||
const slotId = readEquipmentSlotId(request);
|
||||
if (!slotId) {
|
||||
throw invalidRequest('equipment_unequip 缺少合法 slotId');
|
||||
}
|
||||
return unequipInventoryItem(state, slotId);
|
||||
}
|
||||
case 'forge_craft': {
|
||||
const recipeId = readRecipeId(request);
|
||||
if (!recipeId) {
|
||||
throw invalidRequest('forge_craft 缺少 recipeId');
|
||||
}
|
||||
return craftForgeRecipe(state, recipeId);
|
||||
}
|
||||
case 'forge_dismantle': {
|
||||
const itemId = readItemId(request);
|
||||
if (!itemId) {
|
||||
throw invalidRequest('forge_dismantle 缺少 itemId');
|
||||
}
|
||||
return dismantleInventoryItem(state, itemId);
|
||||
}
|
||||
case 'forge_reforge': {
|
||||
const itemId = readItemId(request);
|
||||
if (!itemId) {
|
||||
throw invalidRequest('forge_reforge 缺少 itemId');
|
||||
}
|
||||
return reforgeInventoryItem(state, itemId);
|
||||
}
|
||||
default:
|
||||
throw invalidRequest(`暂不支持的 Inventory 动作:${request.action.functionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function isSupportedInventoryStoryFunctionId(functionId: string) {
|
||||
return SUPPORTED_INVENTORY_STORY_FUNCTION_IDS.has(functionId);
|
||||
}
|
||||
|
||||
export function resolveInventoryStoryAction(
|
||||
session: RuntimeSession,
|
||||
request: RuntimeStoryActionRequest,
|
||||
): InventoryStoryResolution {
|
||||
const mutation = resolveMutation(
|
||||
request,
|
||||
session.rawGameState as InventoryRuntimeGameState,
|
||||
);
|
||||
if (!mutation.ok) {
|
||||
throwMutationFailure(mutation);
|
||||
}
|
||||
|
||||
refreshSessionFromGameState(session, mutation.nextState);
|
||||
|
||||
return {
|
||||
actionText: mutation.actionText,
|
||||
resultText: mutation.detailText,
|
||||
patches: [],
|
||||
toast: buildBuildToast(mutation.nextState),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import type {
|
||||
RuntimeStoryActionRequest,
|
||||
RuntimeStoryPatch,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import { conflict, invalidRequest } from '../../errors.js';
|
||||
import {
|
||||
addInventoryItems,
|
||||
appendStoryEngineCarrierMemory,
|
||||
applyStoryChoiceToStanceProfile,
|
||||
buildInitialNpcState,
|
||||
buildNpcGiftCommitActionText,
|
||||
buildNpcGiftResultText,
|
||||
buildNpcTradeTransactionActionText,
|
||||
buildNpcTradeTransactionResultText,
|
||||
buildRelationState,
|
||||
getGiftCandidates,
|
||||
getNpcBuybackPrice,
|
||||
getNpcPurchasePrice,
|
||||
markNpcFirstMeaningfulContactResolved,
|
||||
normalizeNpcPersistentState,
|
||||
removeInventoryItem,
|
||||
syncNpcTradeInventory,
|
||||
} from '../../bridges/legacyNpcTask6Bridge.js';
|
||||
import {
|
||||
replaceRuntimeSessionRawGameState,
|
||||
type RuntimeSession,
|
||||
} from '../story/runtimeSession.js';
|
||||
|
||||
const SUPPORTED_NPC_INVENTORY_STORY_FUNCTION_IDS = new Set<string>([
|
||||
'npc_gift',
|
||||
'npc_trade',
|
||||
]);
|
||||
|
||||
type NpcInventoryStoryResolution = {
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
patches: RuntimeStoryPatch[];
|
||||
};
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type RuntimeInventoryItem = Parameters<typeof addInventoryItems>[1][number];
|
||||
type RuntimeGameState = Parameters<typeof syncNpcTradeInventory>[0];
|
||||
type RuntimeEncounter = Parameters<typeof buildInitialNpcState>[0];
|
||||
|
||||
function isObject(value: unknown): value is JsonRecord {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readPayload(request: RuntimeStoryActionRequest) {
|
||||
return isObject(request.action.payload) ? request.action.payload : {};
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
function readPositiveInteger(value: unknown, fallback = 1) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.max(1, Math.floor(value));
|
||||
}
|
||||
|
||||
function cloneInventoryItemForOwner(
|
||||
item: RuntimeInventoryItem,
|
||||
owner: 'player' | 'npc',
|
||||
quantity = 1,
|
||||
): RuntimeInventoryItem {
|
||||
const preserveIdentity = Boolean(
|
||||
item.runtimeMetadata ||
|
||||
item.buildProfile ||
|
||||
item.equipmentSlotId ||
|
||||
item.statProfile ||
|
||||
item.attributeResonance,
|
||||
);
|
||||
|
||||
return {
|
||||
...item,
|
||||
id: preserveIdentity
|
||||
? `${owner}:${item.id}:${quantity}`
|
||||
: `${owner}:${encodeURIComponent(`${item.category}-${item.name}`)}`,
|
||||
quantity,
|
||||
runtimeMetadata: item.runtimeMetadata
|
||||
? {
|
||||
...item.runtimeMetadata,
|
||||
seedKey: `${item.runtimeMetadata.seedKey}:${owner}`,
|
||||
}
|
||||
: item.runtimeMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
function getNpcEncounterKey(encounter: RuntimeEncounter) {
|
||||
return encounter.id?.trim() || encounter.npcName;
|
||||
}
|
||||
|
||||
function getNpcEncounter(
|
||||
session: RuntimeSession,
|
||||
state: RuntimeGameState,
|
||||
): RuntimeEncounter | null {
|
||||
const rawEncounter = state.currentEncounter;
|
||||
if (!rawEncounter || rawEncounter.kind !== 'npc') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
npcAvatar: '',
|
||||
hostile: false,
|
||||
...rawEncounter,
|
||||
id: rawEncounter.id ?? session.currentEncounter?.id ?? rawEncounter.npcName,
|
||||
} satisfies RuntimeEncounter;
|
||||
}
|
||||
|
||||
export function ensureNpcInventorySessionState(session: RuntimeSession) {
|
||||
const state = session.rawGameState as unknown as RuntimeGameState;
|
||||
const encounter = getNpcEncounter(session, state);
|
||||
if (!encounter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const npcKey = getNpcEncounterKey(encounter);
|
||||
const baseNpcState =
|
||||
state.npcStates?.[npcKey] ??
|
||||
buildInitialNpcState(encounter, state.worldType, state);
|
||||
const normalizedNpcState = normalizeNpcPersistentState(baseNpcState);
|
||||
const syncedNpcState = syncNpcTradeInventory(state, encounter, normalizedNpcState);
|
||||
|
||||
const nextState = {
|
||||
...state,
|
||||
npcStates: {
|
||||
...(state.npcStates ?? {}),
|
||||
[npcKey]: syncedNpcState,
|
||||
},
|
||||
} satisfies RuntimeGameState;
|
||||
|
||||
replaceRuntimeSessionRawGameState(
|
||||
session,
|
||||
nextState as unknown as JsonRecord,
|
||||
);
|
||||
}
|
||||
|
||||
function getCurrentNpcState(session: RuntimeSession) {
|
||||
const state = session.rawGameState as unknown as RuntimeGameState;
|
||||
const encounter = getNpcEncounter(session, state);
|
||||
if (!encounter) {
|
||||
throw conflict('当前不在可结算的 NPC 交互态,无法执行交易或赠礼。');
|
||||
}
|
||||
|
||||
const npcKey = getNpcEncounterKey(encounter);
|
||||
const npcState = state.npcStates?.[npcKey];
|
||||
if (!npcState) {
|
||||
throw conflict('当前 NPC 状态不存在,无法继续结算。');
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
encounter,
|
||||
npcKey,
|
||||
npcState,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTradeMode(request: RuntimeStoryActionRequest) {
|
||||
const mode = readString(readPayload(request).mode);
|
||||
if (mode === 'buy' || mode === 'sell') {
|
||||
return mode;
|
||||
}
|
||||
|
||||
throw invalidRequest('npc_trade 缺少合法 mode,需为 buy 或 sell');
|
||||
}
|
||||
|
||||
function readTradeItemId(request: RuntimeStoryActionRequest) {
|
||||
const payload = readPayload(request);
|
||||
return (
|
||||
readString(payload.itemId) ||
|
||||
readString(payload.selectedNpcItemId) ||
|
||||
readString(payload.selectedPlayerItemId) ||
|
||||
readString(request.action.targetId)
|
||||
);
|
||||
}
|
||||
|
||||
function readTradeQuantity(request: RuntimeStoryActionRequest) {
|
||||
return readPositiveInteger(readPayload(request).quantity, 1);
|
||||
}
|
||||
|
||||
function resolveNpcTradeAction(
|
||||
session: RuntimeSession,
|
||||
request: RuntimeStoryActionRequest,
|
||||
): NpcInventoryStoryResolution {
|
||||
ensureNpcInventorySessionState(session);
|
||||
const { state, encounter, npcKey, npcState } = getCurrentNpcState(session);
|
||||
const mode = resolveTradeMode(request);
|
||||
const itemId = readTradeItemId(request);
|
||||
const quantity = readTradeQuantity(request);
|
||||
|
||||
if (!itemId) {
|
||||
throw invalidRequest('npc_trade 缺少 itemId');
|
||||
}
|
||||
|
||||
if (mode === 'buy') {
|
||||
const npcItem = npcState.inventory.find((item) => item.id === itemId);
|
||||
if (!npcItem || npcItem.quantity < quantity) {
|
||||
throw conflict('目标商品不存在或库存不足。');
|
||||
}
|
||||
|
||||
const totalPrice = getNpcPurchasePrice(npcItem, npcState.affinity) * quantity;
|
||||
if (state.playerCurrency < totalPrice) {
|
||||
throw conflict('当前钱币不足,无法完成购买。');
|
||||
}
|
||||
|
||||
const acquiredItem = cloneInventoryItemForOwner(npcItem, 'player', quantity);
|
||||
let nextState = {
|
||||
...state,
|
||||
playerCurrency: state.playerCurrency - totalPrice,
|
||||
playerInventory: addInventoryItems(state.playerInventory, [acquiredItem]),
|
||||
npcStates: {
|
||||
...state.npcStates,
|
||||
[npcKey]: {
|
||||
...markNpcFirstMeaningfulContactResolved(npcState),
|
||||
inventory: removeInventoryItem(npcState.inventory, npcItem.id, quantity),
|
||||
},
|
||||
},
|
||||
} satisfies RuntimeGameState;
|
||||
nextState = appendStoryEngineCarrierMemory(nextState, [acquiredItem]);
|
||||
|
||||
replaceRuntimeSessionRawGameState(
|
||||
session,
|
||||
nextState as unknown as JsonRecord,
|
||||
);
|
||||
|
||||
return {
|
||||
actionText: buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
mode: 'buy',
|
||||
item: npcItem,
|
||||
quantity,
|
||||
}),
|
||||
resultText: buildNpcTradeTransactionResultText({
|
||||
encounter,
|
||||
mode: 'buy',
|
||||
item: npcItem,
|
||||
quantity,
|
||||
totalPrice,
|
||||
worldType: state.worldType,
|
||||
}),
|
||||
patches: [],
|
||||
};
|
||||
}
|
||||
|
||||
const playerItem = state.playerInventory.find((item) => item.id === itemId);
|
||||
if (!playerItem || playerItem.quantity < quantity) {
|
||||
throw conflict('背包里没有足够数量的目标物品。');
|
||||
}
|
||||
|
||||
const totalPrice = getNpcBuybackPrice(playerItem, npcState.affinity) * quantity;
|
||||
const soldItem = cloneInventoryItemForOwner(playerItem, 'npc', quantity);
|
||||
const nextState = {
|
||||
...state,
|
||||
playerCurrency: state.playerCurrency + totalPrice,
|
||||
playerInventory: removeInventoryItem(state.playerInventory, playerItem.id, quantity),
|
||||
npcStates: {
|
||||
...state.npcStates,
|
||||
[npcKey]: {
|
||||
...markNpcFirstMeaningfulContactResolved(npcState),
|
||||
inventory: addInventoryItems(npcState.inventory, [soldItem]),
|
||||
},
|
||||
},
|
||||
} satisfies RuntimeGameState;
|
||||
|
||||
replaceRuntimeSessionRawGameState(
|
||||
session,
|
||||
nextState as unknown as JsonRecord,
|
||||
);
|
||||
|
||||
return {
|
||||
actionText: buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
mode: 'sell',
|
||||
item: playerItem,
|
||||
quantity,
|
||||
}),
|
||||
resultText: buildNpcTradeTransactionResultText({
|
||||
encounter,
|
||||
mode: 'sell',
|
||||
item: playerItem,
|
||||
quantity,
|
||||
totalPrice,
|
||||
worldType: state.worldType,
|
||||
}),
|
||||
patches: [],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveNpcGiftAction(
|
||||
session: RuntimeSession,
|
||||
request: RuntimeStoryActionRequest,
|
||||
): NpcInventoryStoryResolution {
|
||||
ensureNpcInventorySessionState(session);
|
||||
const { state, encounter, npcKey, npcState } = getCurrentNpcState(session);
|
||||
const itemId =
|
||||
readString(readPayload(request).itemId) || readString(request.action.targetId);
|
||||
|
||||
if (!itemId) {
|
||||
throw invalidRequest('npc_gift 缺少 itemId');
|
||||
}
|
||||
|
||||
const giftItem = state.playerInventory.find((item) => item.id === itemId);
|
||||
if (!giftItem || giftItem.quantity <= 0) {
|
||||
throw conflict('背包里没有这件可赠送的物品。');
|
||||
}
|
||||
|
||||
const giftCandidate = getGiftCandidates(state.playerInventory, encounter, {
|
||||
worldType: state.worldType,
|
||||
customWorldProfile: state.customWorldProfile,
|
||||
}).find((candidate) => candidate.item.id === giftItem.id);
|
||||
const affinityGain = giftCandidate?.affinityGain ?? 0;
|
||||
const attributeSummary = giftCandidate?.attributeInsight?.reasonText ?? undefined;
|
||||
const nextAffinity = npcState.affinity + affinityGain;
|
||||
const nextNpcState = {
|
||||
...markNpcFirstMeaningfulContactResolved(npcState),
|
||||
affinity: nextAffinity,
|
||||
relationState: buildRelationState(nextAffinity),
|
||||
giftsGiven: (npcState.giftsGiven ?? 0) + 1,
|
||||
stanceProfile: applyStoryChoiceToStanceProfile(
|
||||
npcState.stanceProfile,
|
||||
'npc_gift',
|
||||
{ affinityGain },
|
||||
),
|
||||
inventory: addInventoryItems(npcState.inventory, [
|
||||
cloneInventoryItemForOwner(giftItem, 'npc'),
|
||||
]),
|
||||
};
|
||||
|
||||
const nextState = {
|
||||
...state,
|
||||
playerInventory: removeInventoryItem(state.playerInventory, giftItem.id, 1),
|
||||
npcStates: {
|
||||
...state.npcStates,
|
||||
[npcKey]: nextNpcState,
|
||||
},
|
||||
} satisfies RuntimeGameState;
|
||||
|
||||
replaceRuntimeSessionRawGameState(
|
||||
session,
|
||||
nextState as unknown as JsonRecord,
|
||||
);
|
||||
|
||||
return {
|
||||
actionText: buildNpcGiftCommitActionText(encounter, giftItem),
|
||||
resultText: buildNpcGiftResultText(
|
||||
encounter,
|
||||
giftItem,
|
||||
affinityGain,
|
||||
nextAffinity,
|
||||
attributeSummary,
|
||||
),
|
||||
patches: [
|
||||
{
|
||||
type: 'npc_affinity_changed',
|
||||
npcId: npcKey,
|
||||
previousAffinity: npcState.affinity,
|
||||
nextAffinity,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function isSupportedNpcInventoryStoryFunctionId(functionId: string) {
|
||||
return SUPPORTED_NPC_INVENTORY_STORY_FUNCTION_IDS.has(functionId);
|
||||
}
|
||||
|
||||
export function resolveNpcInventoryStoryAction(
|
||||
session: RuntimeSession,
|
||||
request: RuntimeStoryActionRequest,
|
||||
): NpcInventoryStoryResolution {
|
||||
switch (request.action.functionId) {
|
||||
case 'npc_trade':
|
||||
return resolveNpcTradeAction(session, request);
|
||||
case 'npc_gift':
|
||||
return resolveNpcGiftAction(session, request);
|
||||
default:
|
||||
throw invalidRequest(
|
||||
`暂不支持的 NPC Inventory 动作:${request.action.functionId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
261
server-node/src/modules/npc/npcInteractionService.ts
Normal file
261
server-node/src/modules/npc/npcInteractionService.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import type { RuntimeStoryPatch } from '../../../../packages/shared/src/contracts/story.js';
|
||||
import { conflict } from '../../errors.js';
|
||||
import {
|
||||
MAX_TASK5_COMPANIONS,
|
||||
getEncounterNpcState,
|
||||
setEncounterNpcState,
|
||||
type RuntimeEncounter,
|
||||
type RuntimeNpcState,
|
||||
type RuntimeSession,
|
||||
} from '../story/runtimeSession.js';
|
||||
|
||||
export type NpcInteractionResolution = {
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
patches: RuntimeStoryPatch[];
|
||||
storyText?: string;
|
||||
toast?: string | null;
|
||||
};
|
||||
|
||||
function requireNpcEncounter(session: RuntimeSession) {
|
||||
if (!session.currentEncounter || session.currentEncounter.kind !== 'npc') {
|
||||
throw conflict('当前没有可结算的 NPC 交互对象');
|
||||
}
|
||||
|
||||
return session.currentEncounter;
|
||||
}
|
||||
|
||||
function requireNpcState(
|
||||
session: RuntimeSession,
|
||||
encounter: RuntimeEncounter,
|
||||
): RuntimeNpcState {
|
||||
const npcState = getEncounterNpcState(session);
|
||||
if (!npcState) {
|
||||
throw conflict(`未找到 ${encounter.npcName} 的运行时关系状态`);
|
||||
}
|
||||
|
||||
return npcState;
|
||||
}
|
||||
|
||||
function buildAffinityPatch(
|
||||
encounter: RuntimeEncounter,
|
||||
previousAffinity: number,
|
||||
nextAffinity: number,
|
||||
) {
|
||||
return {
|
||||
type: 'npc_affinity_changed',
|
||||
npcId: encounter.id,
|
||||
previousAffinity,
|
||||
nextAffinity,
|
||||
} satisfies RuntimeStoryPatch;
|
||||
}
|
||||
|
||||
function buildBattleTarget(
|
||||
encounter: RuntimeEncounter,
|
||||
npcState: RuntimeNpcState,
|
||||
mode: 'fight' | 'spar',
|
||||
) {
|
||||
const maxHp =
|
||||
mode === 'spar'
|
||||
? 8
|
||||
: Math.max(32, 24 + Math.max(0, Math.round(npcState.affinity * 0.35)));
|
||||
|
||||
return {
|
||||
id: encounter.id,
|
||||
name: encounter.npcName,
|
||||
hp: maxHp,
|
||||
maxHp,
|
||||
description: encounter.npcDescription,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveNpcInteraction(
|
||||
session: RuntimeSession,
|
||||
functionId: string,
|
||||
): NpcInteractionResolution {
|
||||
const encounter = requireNpcEncounter(session);
|
||||
const npcState = requireNpcState(session, encounter);
|
||||
|
||||
switch (functionId) {
|
||||
case 'npc_preview_talk': {
|
||||
session.npcInteractionActive = true;
|
||||
return {
|
||||
actionText: `转向${encounter.npcName}`,
|
||||
resultText: `你把注意力真正收回到${encounter.npcName}身上,接下来可以围绕这名角色做正式交互了。`,
|
||||
patches: [
|
||||
{
|
||||
type: 'status_changed',
|
||||
inBattle: session.inBattle,
|
||||
npcInteractionActive: session.npcInteractionActive,
|
||||
currentNpcBattleMode: session.currentNpcBattleMode,
|
||||
currentNpcBattleOutcome: session.currentNpcBattleOutcome,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
case 'npc_chat': {
|
||||
session.npcInteractionActive = true;
|
||||
const affinityGain = Math.max(2, 6 - npcState.chattedCount);
|
||||
const nextAffinity = npcState.affinity + affinityGain;
|
||||
setEncounterNpcState(session, {
|
||||
...npcState,
|
||||
affinity: nextAffinity,
|
||||
chattedCount: npcState.chattedCount + 1,
|
||||
firstMeaningfulContactResolved: true,
|
||||
});
|
||||
|
||||
return {
|
||||
actionText: `继续和${encounter.npcName}交谈`,
|
||||
resultText: `${encounter.npcName}愿意把话接下去,态度比刚才明显松动了一些。当前关系推进了 ${affinityGain} 点。`,
|
||||
patches: [
|
||||
buildAffinityPatch(encounter, npcState.affinity, nextAffinity),
|
||||
{
|
||||
type: 'status_changed',
|
||||
inBattle: session.inBattle,
|
||||
npcInteractionActive: session.npcInteractionActive,
|
||||
currentNpcBattleMode: session.currentNpcBattleMode,
|
||||
currentNpcBattleOutcome: session.currentNpcBattleOutcome,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
case 'npc_help': {
|
||||
if (npcState.helpUsed) {
|
||||
throw conflict('当前 NPC 的一次性援手已经用完了');
|
||||
}
|
||||
|
||||
const previousAffinity = npcState.affinity;
|
||||
const nextAffinity = previousAffinity + 4;
|
||||
session.playerHp = Math.min(session.playerMaxHp, session.playerHp + 10);
|
||||
session.playerMana = Math.min(session.playerMaxMana, session.playerMana + 8);
|
||||
setEncounterNpcState(session, {
|
||||
...npcState,
|
||||
affinity: nextAffinity,
|
||||
helpUsed: true,
|
||||
});
|
||||
|
||||
return {
|
||||
actionText: `向${encounter.npcName}请求援手`,
|
||||
resultText: `${encounter.npcName}给了你一次及时支援,你的状态暂时稳住了,关系也顺势拉近了一点。`,
|
||||
patches: [
|
||||
buildAffinityPatch(encounter, previousAffinity, nextAffinity),
|
||||
{
|
||||
type: 'status_changed',
|
||||
inBattle: session.inBattle,
|
||||
npcInteractionActive: session.npcInteractionActive,
|
||||
currentNpcBattleMode: session.currentNpcBattleMode,
|
||||
currentNpcBattleOutcome: session.currentNpcBattleOutcome,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
case 'npc_recruit': {
|
||||
if (npcState.recruited) {
|
||||
throw conflict('当前 NPC 已经处于已招募状态');
|
||||
}
|
||||
if (npcState.affinity < 60) {
|
||||
throw conflict('当前关系还没达到招募阈值,暂时不能邀请入队');
|
||||
}
|
||||
if (session.companions.length >= MAX_TASK5_COMPANIONS) {
|
||||
throw conflict('队伍已满,任务5首轮后端接口暂不处理换队逻辑');
|
||||
}
|
||||
|
||||
setEncounterNpcState(session, {
|
||||
...npcState,
|
||||
recruited: true,
|
||||
firstMeaningfulContactResolved: true,
|
||||
});
|
||||
session.companions.push({
|
||||
npcId: encounter.id,
|
||||
characterId: encounter.characterId ?? '',
|
||||
joinedAtAffinity: npcState.affinity,
|
||||
});
|
||||
session.currentEncounter = null;
|
||||
session.npcInteractionActive = false;
|
||||
session.currentNpcBattleMode = null;
|
||||
session.currentNpcBattleOutcome = null;
|
||||
session.inBattle = false;
|
||||
session.sceneHostileNpcs = [];
|
||||
|
||||
return {
|
||||
actionText: `邀请${encounter.npcName}加入队伍`,
|
||||
resultText: `${encounter.npcName}接受了你的邀请,正式进入了同行队伍。`,
|
||||
patches: [
|
||||
{
|
||||
type: 'status_changed',
|
||||
inBattle: session.inBattle,
|
||||
npcInteractionActive: session.npcInteractionActive,
|
||||
currentNpcBattleMode: session.currentNpcBattleMode,
|
||||
currentNpcBattleOutcome: session.currentNpcBattleOutcome,
|
||||
},
|
||||
{
|
||||
type: 'encounter_changed',
|
||||
encounterId: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
case 'npc_fight':
|
||||
case 'npc_spar': {
|
||||
session.npcInteractionActive = false;
|
||||
session.inBattle = true;
|
||||
session.currentNpcBattleMode = functionId === 'npc_spar' ? 'spar' : 'fight';
|
||||
session.currentNpcBattleOutcome = null;
|
||||
session.sceneHostileNpcs = [
|
||||
buildBattleTarget(
|
||||
encounter,
|
||||
npcState,
|
||||
functionId === 'npc_spar' ? 'spar' : 'fight',
|
||||
),
|
||||
];
|
||||
|
||||
return {
|
||||
actionText:
|
||||
functionId === 'npc_spar'
|
||||
? `与${encounter.npcName}点到为止切磋`
|
||||
: `与${encounter.npcName}正面开战`,
|
||||
resultText:
|
||||
functionId === 'npc_spar'
|
||||
? `${encounter.npcName}摆开架势,准备和你来一场点到为止的切磋。`
|
||||
: `${encounter.npcName}已经不再保留余地,当前冲突正式转入战斗结算。`,
|
||||
patches: [
|
||||
{
|
||||
type: 'status_changed',
|
||||
inBattle: session.inBattle,
|
||||
npcInteractionActive: session.npcInteractionActive,
|
||||
currentNpcBattleMode: session.currentNpcBattleMode,
|
||||
currentNpcBattleOutcome: session.currentNpcBattleOutcome,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
case 'npc_leave': {
|
||||
session.currentEncounter = null;
|
||||
session.npcInteractionActive = false;
|
||||
session.currentNpcBattleMode = null;
|
||||
session.currentNpcBattleOutcome = null;
|
||||
session.sceneHostileNpcs = [];
|
||||
session.inBattle = false;
|
||||
|
||||
return {
|
||||
actionText: `离开${encounter.npcName}`,
|
||||
resultText: `你暂时没有继续和${encounter.npcName}纠缠,把注意力重新拉回了前路。`,
|
||||
patches: [
|
||||
{
|
||||
type: 'status_changed',
|
||||
inBattle: session.inBattle,
|
||||
npcInteractionActive: session.npcInteractionActive,
|
||||
currentNpcBattleMode: session.currentNpcBattleMode,
|
||||
currentNpcBattleOutcome: session.currentNpcBattleOutcome,
|
||||
},
|
||||
{
|
||||
type: 'encounter_changed',
|
||||
encounterId: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw conflict(`暂不支持的 NPC 动作:${functionId}`);
|
||||
}
|
||||
}
|
||||
150
server-node/src/modules/npc/npcTask6Primitives.test.ts
Normal file
150
server-node/src/modules/npc/npcTask6Primitives.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
getGiftCandidates,
|
||||
syncNpcTradeInventory,
|
||||
} from './npcTask6Primitives.js';
|
||||
|
||||
function createState(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
customWorldProfile: null,
|
||||
currentScenePreset: {
|
||||
id: 'market-street',
|
||||
name: '桥市',
|
||||
description: '桥下的临时市集还没有散。',
|
||||
treasureHints: [],
|
||||
},
|
||||
storyHistory: [
|
||||
{
|
||||
text: '你刚从桥口撤下来,正准备补足补给。',
|
||||
},
|
||||
],
|
||||
playerCharacter: createTestPlayerCharacter<{ id: string }>(),
|
||||
playerEquipment: {
|
||||
weapon: {
|
||||
tags: ['weapon', '快剑'],
|
||||
buildProfile: {
|
||||
role: '快剑',
|
||||
tags: ['快剑', '突进'],
|
||||
},
|
||||
},
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
activeBuildBuffs: [
|
||||
{
|
||||
tags: ['续战'],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('buildInitialNpcState generates deterministic trade stock for runtime role npc', () => {
|
||||
const state = createState();
|
||||
const npcState = buildInitialNpcState(
|
||||
{
|
||||
id: 'npc_vendor_01',
|
||||
npcName: '桥市货郎',
|
||||
npcDescription: '背着木箱沿街兜售补给的行脚货郎。',
|
||||
context: '沿街商贩',
|
||||
},
|
||||
'WUXIA',
|
||||
state,
|
||||
);
|
||||
|
||||
assert.equal(npcState.affinity, 6);
|
||||
assert.equal(typeof npcState.tradeStockSignature, 'string');
|
||||
assert.ok((npcState.tradeStockSignature ?? '').includes('npc_vendor_01'));
|
||||
assert.ok(npcState.inventory.length > 0);
|
||||
assert.ok(
|
||||
npcState.inventory.every(
|
||||
(item) => item.runtimeMetadata?.generationChannel === 'npc_trade',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('syncNpcTradeInventory keeps non-trade items while refreshing generated stock', () => {
|
||||
const state = createState({
|
||||
activeBuildBuffs: [{ tags: ['爆发'] }],
|
||||
});
|
||||
const nextState = syncNpcTradeInventory(
|
||||
state,
|
||||
{
|
||||
id: 'npc_vendor_02',
|
||||
npcName: '药铺掌柜',
|
||||
npcDescription: '一边记账一边看着药炉火候。',
|
||||
context: '药商',
|
||||
},
|
||||
{
|
||||
affinity: 14,
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 1,
|
||||
inventory: [
|
||||
{
|
||||
id: 'gift-token',
|
||||
category: '信物',
|
||||
name: '旧铜铃',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
tags: ['relic'],
|
||||
runtimeMetadata: {
|
||||
generationChannel: 'npc_gift',
|
||||
},
|
||||
},
|
||||
],
|
||||
recruited: false,
|
||||
tradeStockSignature: 'outdated-signature',
|
||||
firstMeaningfulContactResolved: true,
|
||||
knownAttributeRumors: [],
|
||||
revealedFacts: [],
|
||||
seenBackstoryChapterIds: [],
|
||||
},
|
||||
);
|
||||
|
||||
assert.ok(nextState.inventory.some((item) => item.id === 'gift-token'));
|
||||
assert.ok(
|
||||
nextState.inventory.some(
|
||||
(item) => item.runtimeMetadata?.generationChannel === 'npc_trade',
|
||||
),
|
||||
);
|
||||
assert.notEqual(nextState.tradeStockSignature, 'outdated-signature');
|
||||
});
|
||||
|
||||
test('getGiftCandidates prefers gifts that match npc role tags', () => {
|
||||
const candidates = getGiftCandidates(
|
||||
[
|
||||
{
|
||||
id: 'mana-herb',
|
||||
category: '材料',
|
||||
name: '暖息草',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
tags: ['material', 'mana'],
|
||||
},
|
||||
{
|
||||
id: 'plain-stone',
|
||||
category: '材料',
|
||||
name: '碎石',
|
||||
quantity: 1,
|
||||
rarity: 'common',
|
||||
tags: ['material'],
|
||||
},
|
||||
],
|
||||
{
|
||||
id: 'npc_vendor_03',
|
||||
npcName: '药行掌柜',
|
||||
npcDescription: '对药性和回气补给都很熟。',
|
||||
context: '药商',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(candidates[0]?.item.id, 'mana-herb');
|
||||
assert.ok((candidates[0]?.affinityGain ?? 0) > (candidates[1]?.affinityGain ?? 0));
|
||||
assert.match(candidates[0]?.attributeInsight?.reasonText ?? '', /回气|补给/u);
|
||||
});
|
||||
411
server-node/src/modules/npc/npcTask6Primitives.ts
Normal file
411
server-node/src/modules/npc/npcTask6Primitives.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
import { formatCurrency } from '../runtime/runtimeEconomyPrimitives.js';
|
||||
import { buildRelationState, sortInventoryItems } from '../runtime/runtimeStatePrimitives.js';
|
||||
import {
|
||||
buildLooseRuntimeItemGenerationContext,
|
||||
buildRuntimeInventoryStock,
|
||||
} from '../runtime-item/runtimeItemModule.js';
|
||||
import { normalizeNpcPersistentState } from '../runtime/runtimeNpcStatePrimitives.js';
|
||||
|
||||
type RuntimeInventoryItem = {
|
||||
id: string;
|
||||
category: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
||||
tags: string[];
|
||||
runtimeMetadata?: {
|
||||
generationChannel?: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type RuntimeEncounter = {
|
||||
id?: string;
|
||||
npcName: string;
|
||||
context: string;
|
||||
characterId?: string | null;
|
||||
monsterPresetId?: string | null;
|
||||
initialAffinity?: number;
|
||||
};
|
||||
|
||||
type RuntimeNpcState = {
|
||||
affinity: number;
|
||||
helpUsed: boolean;
|
||||
chattedCount: number;
|
||||
giftsGiven: number;
|
||||
inventory: RuntimeInventoryItem[];
|
||||
recruited: boolean;
|
||||
relationState?: ReturnType<typeof buildRelationState>;
|
||||
revealedFacts?: string[];
|
||||
knownAttributeRumors?: string[];
|
||||
firstMeaningfulContactResolved?: boolean;
|
||||
seenBackstoryChapterIds?: string[];
|
||||
tradeStockSignature?: string | null;
|
||||
stanceProfile?: {
|
||||
trust?: number;
|
||||
warmth?: number;
|
||||
ideologicalFit?: number;
|
||||
fearOrGuard?: number;
|
||||
loyalty?: number;
|
||||
currentConflictTag?: string | null;
|
||||
recentApprovals?: string[];
|
||||
recentDisapprovals?: string[];
|
||||
} | null;
|
||||
};
|
||||
|
||||
function clampStanceMetric(value: number) {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
function buildInitialStanceProfile(
|
||||
affinity: number,
|
||||
options: {
|
||||
recruited?: boolean;
|
||||
hostile?: boolean;
|
||||
roleText?: string | null;
|
||||
} = {},
|
||||
) {
|
||||
const recruitedBonus = options.recruited ? 14 : 0;
|
||||
const hostilePenalty = options.hostile ? 18 : 0;
|
||||
const roleText = options.roleText ?? '';
|
||||
const currentConflictTag =
|
||||
/旧案|调查|追查/u.test(roleText)
|
||||
? '旧案'
|
||||
: /守|卫|巡/u.test(roleText)
|
||||
? '守线'
|
||||
: /商|摊|军需/u.test(roleText)
|
||||
? '交易'
|
||||
: null;
|
||||
|
||||
return {
|
||||
trust: clampStanceMetric(42 + affinity * 0.55 + recruitedBonus - hostilePenalty),
|
||||
warmth: clampStanceMetric(36 + affinity * 0.5 + recruitedBonus),
|
||||
ideologicalFit: clampStanceMetric(48 + affinity * 0.25),
|
||||
fearOrGuard: clampStanceMetric(62 - affinity * 0.55 + hostilePenalty),
|
||||
loyalty: clampStanceMetric(24 + affinity * 0.35 + (options.recruited ? 26 : 0)),
|
||||
currentConflictTag,
|
||||
recentApprovals: [],
|
||||
recentDisapprovals: [],
|
||||
};
|
||||
}
|
||||
|
||||
function getRarityScore(rarity: RuntimeInventoryItem['rarity']) {
|
||||
switch (rarity) {
|
||||
case 'legendary':
|
||||
return 5;
|
||||
case 'epic':
|
||||
return 4;
|
||||
case 'rare':
|
||||
return 3;
|
||||
case 'uncommon':
|
||||
return 2;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function describeAffinityShift(affinityGain: number) {
|
||||
if (affinityGain >= 12) return '态度一下子软化了许多';
|
||||
if (affinityGain >= 8) return '态度明显和缓下来';
|
||||
if (affinityGain >= 5) return '态度比先前亲近了一些';
|
||||
return '态度略微放松了些';
|
||||
}
|
||||
|
||||
function describeNpcAffinityInWords(
|
||||
affinity: number,
|
||||
options: { recruited?: boolean } = {},
|
||||
) {
|
||||
if (options.recruited) {
|
||||
return '已经把你视为并肩而行的同伴,交流时天然站在你这一边。';
|
||||
}
|
||||
if (affinity >= 90) return '对你高度信赖,言谈间明显亲近,几乎已经把你当成自己人。';
|
||||
if (affinity >= 60) return '对你已经建立起稳固信任,愿意进一步合作。';
|
||||
if (affinity >= 30) return '对你的态度明显友善了许多,也更愿意正常交流。';
|
||||
if (affinity >= 15) return '戒备开始松动,愿意试探性地配合你的节奏。';
|
||||
if (affinity >= 0) return '仍保持明显距离,只会给出谨慎而有限的回应。';
|
||||
return '关系已经降到冰点,对你几乎不再保留善意。';
|
||||
}
|
||||
|
||||
function isRuntimeTradeDrivenRoleNpc(encounter: RuntimeEncounter) {
|
||||
return !encounter.characterId && !encounter.monsterPresetId;
|
||||
}
|
||||
|
||||
export function applyStoryChoiceToStanceProfile(
|
||||
stanceProfile: RuntimeNpcState['stanceProfile'],
|
||||
action: 'npc_chat' | 'npc_help' | 'npc_gift' | 'npc_recruit' | 'npc_quest_accept',
|
||||
options: {
|
||||
affinityGain?: number;
|
||||
recruited?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const base =
|
||||
stanceProfile ??
|
||||
buildInitialStanceProfile(0, {
|
||||
recruited: options.recruited,
|
||||
});
|
||||
const affinityGain = options.affinityGain ?? 0;
|
||||
const approvalNotes = [...(base.recentApprovals ?? [])];
|
||||
const disapprovalNotes = [...(base.recentDisapprovals ?? [])];
|
||||
|
||||
const applyApproval = (note: string) => {
|
||||
approvalNotes.push(note);
|
||||
while (approvalNotes.length > 3) approvalNotes.shift();
|
||||
};
|
||||
const applyDisapproval = (note: string) => {
|
||||
disapprovalNotes.push(note);
|
||||
while (disapprovalNotes.length > 3) disapprovalNotes.shift();
|
||||
};
|
||||
|
||||
const next = {
|
||||
...base,
|
||||
trust: base.trust ?? 40,
|
||||
warmth: base.warmth ?? 35,
|
||||
ideologicalFit: base.ideologicalFit ?? 45,
|
||||
fearOrGuard: base.fearOrGuard ?? 55,
|
||||
loyalty: base.loyalty ?? 20,
|
||||
};
|
||||
|
||||
switch (action) {
|
||||
case 'npc_chat':
|
||||
next.trust += 6 + affinityGain * 2;
|
||||
next.warmth += 4 + affinityGain * 2;
|
||||
next.fearOrGuard -= 5 + affinityGain;
|
||||
if (affinityGain >= 0) {
|
||||
applyApproval('你愿意先从眼前局势和试探开始说话。');
|
||||
} else {
|
||||
applyDisapproval('这轮交流没能真正对上节奏。');
|
||||
}
|
||||
break;
|
||||
case 'npc_help':
|
||||
next.trust += 12;
|
||||
next.warmth += 6;
|
||||
next.fearOrGuard -= 8;
|
||||
applyApproval('你在对方需要的时候搭了手。');
|
||||
break;
|
||||
case 'npc_gift':
|
||||
next.trust += 6 + affinityGain;
|
||||
next.warmth += 10 + affinityGain * 2;
|
||||
next.fearOrGuard -= 4;
|
||||
applyApproval('你给出的东西回应了对方眼下的处境。');
|
||||
break;
|
||||
case 'npc_recruit':
|
||||
next.trust += 8;
|
||||
next.warmth += 6;
|
||||
next.loyalty += 18;
|
||||
next.fearOrGuard -= 10;
|
||||
applyApproval('你正式把对方纳入了同行关系。');
|
||||
break;
|
||||
case 'npc_quest_accept':
|
||||
next.trust += 7;
|
||||
next.ideologicalFit += 5;
|
||||
next.loyalty += 4;
|
||||
applyApproval('你接住了对方主动交出来的事。');
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
...next,
|
||||
trust: clampStanceMetric(next.trust),
|
||||
warmth: clampStanceMetric(next.warmth),
|
||||
ideologicalFit: clampStanceMetric(next.ideologicalFit),
|
||||
fearOrGuard: clampStanceMetric(next.fearOrGuard),
|
||||
loyalty: clampStanceMetric(next.loyalty),
|
||||
recentApprovals: approvalNotes,
|
||||
recentDisapprovals: disapprovalNotes,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildInitialNpcState(
|
||||
encounter: RuntimeEncounter,
|
||||
worldType: string | null | undefined,
|
||||
state?: {
|
||||
currentScenePreset?: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
treasureHints?: string[];
|
||||
} | null;
|
||||
playerCharacter?: {
|
||||
id: string;
|
||||
} | null;
|
||||
} | null,
|
||||
) {
|
||||
const initialAffinity =
|
||||
encounter.initialAffinity ??
|
||||
(encounter.monsterPresetId ? -40 : encounter.characterId ? 18 : 6);
|
||||
const baseState = normalizeNpcPersistentState({
|
||||
affinity: initialAffinity,
|
||||
relationState: buildRelationState(initialAffinity),
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [] as RuntimeInventoryItem[],
|
||||
tradeStockSignature: null,
|
||||
recruited: false,
|
||||
revealedFacts: [],
|
||||
knownAttributeRumors: [],
|
||||
firstMeaningfulContactResolved: false,
|
||||
seenBackstoryChapterIds: [],
|
||||
stanceProfile: buildInitialStanceProfile(initialAffinity, {
|
||||
recruited: false,
|
||||
hostile: Boolean(encounter.monsterPresetId) || initialAffinity < 0,
|
||||
roleText: encounter.context,
|
||||
}),
|
||||
});
|
||||
|
||||
if (state && isRuntimeTradeDrivenRoleNpc(encounter)) {
|
||||
return syncNpcTradeInventory(
|
||||
{
|
||||
worldType,
|
||||
currentScenePreset: state.currentScenePreset ?? null,
|
||||
playerCharacter: state.playerCharacter ?? null,
|
||||
},
|
||||
encounter,
|
||||
baseState,
|
||||
);
|
||||
}
|
||||
|
||||
return baseState;
|
||||
}
|
||||
|
||||
export function getGiftCandidates(
|
||||
playerInventory: RuntimeInventoryItem[],
|
||||
_encounter: RuntimeEncounter,
|
||||
) {
|
||||
return [...playerInventory]
|
||||
.filter((item) => item.quantity > 0)
|
||||
.map((item) => ({
|
||||
item,
|
||||
affinityGain:
|
||||
Math.min(
|
||||
24,
|
||||
4 +
|
||||
getRarityScore(item.rarity) * 3 +
|
||||
(item.tags.includes('mana') ? 3 : 0) +
|
||||
(item.tags.includes('healing') ? 3 : 0),
|
||||
),
|
||||
attributeInsight: {
|
||||
reasonText: item.tags.includes('mana')
|
||||
? '这份礼物明显更适合对方当前的回气与补给需求。'
|
||||
: item.tags.includes('healing')
|
||||
? '这份礼物更像是在照顾对方眼下的补给处境。'
|
||||
: '这份礼物至少表达了你愿意先拿出诚意。',
|
||||
},
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
const diff = right.affinityGain - left.affinityGain;
|
||||
if (diff !== 0) return diff;
|
||||
return getRarityScore(right.item.rarity) - getRarityScore(left.item.rarity);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildNpcGiftResultText(
|
||||
encounter: RuntimeEncounter,
|
||||
item: RuntimeInventoryItem,
|
||||
affinityGain: number,
|
||||
nextAffinity: number,
|
||||
attributeSummary?: string,
|
||||
) {
|
||||
const summaryText = attributeSummary ? `你感到:${attributeSummary}` : '';
|
||||
return `${encounter.npcName}收下了${item.name},${describeAffinityShift(affinityGain)}。${describeNpcAffinityInWords(nextAffinity)}${summaryText}`;
|
||||
}
|
||||
|
||||
export function buildNpcGiftCommitActionText(
|
||||
encounter: RuntimeEncounter,
|
||||
item: RuntimeInventoryItem,
|
||||
) {
|
||||
return `把${item.name}赠给${encounter.npcName}`;
|
||||
}
|
||||
|
||||
export function buildNpcTradeTransactionResultText(params: {
|
||||
encounter: RuntimeEncounter;
|
||||
mode: 'buy' | 'sell';
|
||||
item: RuntimeInventoryItem;
|
||||
quantity: number;
|
||||
totalPrice: number;
|
||||
worldType: string | null | undefined;
|
||||
}) {
|
||||
const quantityText =
|
||||
params.quantity > 1 ? `${params.item.name} x${params.quantity}` : params.item.name;
|
||||
|
||||
if (params.mode === 'sell') {
|
||||
return `${params.encounter.npcName}收下了${quantityText},付给你${formatCurrency(params.totalPrice, params.worldType)}。`;
|
||||
}
|
||||
|
||||
return `${params.encounter.npcName}收下了${formatCurrency(params.totalPrice, params.worldType)},把${quantityText}卖给了你。`;
|
||||
}
|
||||
|
||||
export function buildNpcTradeTransactionActionText(params: {
|
||||
encounter: RuntimeEncounter;
|
||||
mode: 'buy' | 'sell';
|
||||
item: RuntimeInventoryItem;
|
||||
quantity: number;
|
||||
}) {
|
||||
const quantityText =
|
||||
params.quantity > 1 ? `${params.item.name} x${params.quantity}` : params.item.name;
|
||||
|
||||
if (params.mode === 'sell') {
|
||||
return `把${quantityText}卖给${params.encounter.npcName}`;
|
||||
}
|
||||
|
||||
return `从${params.encounter.npcName}手里买下${quantityText}`;
|
||||
}
|
||||
|
||||
export function syncNpcTradeInventory(
|
||||
state: {
|
||||
worldType: string | null | undefined;
|
||||
currentScenePreset?: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
treasureHints?: string[];
|
||||
} | null;
|
||||
playerCharacter?: {
|
||||
id: string;
|
||||
} | null;
|
||||
},
|
||||
encounter: RuntimeEncounter,
|
||||
npcState: RuntimeNpcState,
|
||||
) {
|
||||
if (!isRuntimeTradeDrivenRoleNpc(encounter)) {
|
||||
return npcState;
|
||||
}
|
||||
|
||||
const tradeStockSignature = `${encounter.id ?? encounter.npcName}:${state.currentScenePreset?.id ?? 'scene'}:${state.worldType ?? 'world'}`;
|
||||
if (npcState.tradeStockSignature === tradeStockSignature) {
|
||||
return npcState;
|
||||
}
|
||||
|
||||
const runtimeStock = buildRuntimeInventoryStock(
|
||||
buildLooseRuntimeItemGenerationContext({
|
||||
worldType: state.worldType,
|
||||
scene: state.currentScenePreset ?? null,
|
||||
encounter: {
|
||||
...encounter,
|
||||
kind: 'npc',
|
||||
npcDescription: encounter.context,
|
||||
npcAvatar: '',
|
||||
context: encounter.context,
|
||||
},
|
||||
playerCharacterId: state.playerCharacter?.id ?? 'npc-trade-preview',
|
||||
generationChannel: 'npc_trade',
|
||||
}),
|
||||
{
|
||||
seedKey: `npc-trade:${encounter.id ?? encounter.npcName}`,
|
||||
itemCount: 4,
|
||||
fixedKinds: ['consumable', 'material', 'relic', 'equipment'],
|
||||
fixedPermanence: ['timed', 'resource', 'permanent', 'permanent'],
|
||||
} as Parameters<typeof buildRuntimeInventoryStock>[1],
|
||||
);
|
||||
|
||||
const preservedInventory = npcState.tradeStockSignature
|
||||
? npcState.inventory.filter(
|
||||
(item) => item.runtimeMetadata?.generationChannel !== 'npc_trade',
|
||||
)
|
||||
: [];
|
||||
|
||||
return normalizeNpcPersistentState({
|
||||
...npcState,
|
||||
inventory: sortInventoryItems([...preservedInventory, ...runtimeStock]),
|
||||
tradeStockSignature,
|
||||
});
|
||||
}
|
||||
2
server-node/src/modules/quest/index.ts
Normal file
2
server-node/src/modules/quest/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './questProgressionService.js';
|
||||
export { generateQuestForNpcEncounter } from '../../services/questService.js';
|
||||
103
server-node/src/modules/quest/questProgressionService.test.ts
Normal file
103
server-node/src/modules/quest/questProgressionService.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildQuestForEncounter } from '../../bridges/legacyQuestProgressBridge.js';
|
||||
import {
|
||||
acknowledgeQuestCompletion,
|
||||
applyQuestSignal,
|
||||
turnInQuest,
|
||||
} from './questProgressionService.js';
|
||||
|
||||
const TEST_WORLD = 'WUXIA' as Parameters<typeof buildQuestForEncounter>[0]['worldType'];
|
||||
|
||||
const TEST_SCENE = {
|
||||
id: 'forest_path',
|
||||
name: 'Forest Path',
|
||||
description: 'A narrow trail with fresh claw marks.',
|
||||
npcs: [
|
||||
{
|
||||
id: 'hostile-wolf-alpha',
|
||||
name: '狼王',
|
||||
description: 'A hostile wolf alpha.',
|
||||
avatar: '狼',
|
||||
role: '敌对角色',
|
||||
monsterPresetId: 'wolf_alpha',
|
||||
initialAffinity: -40,
|
||||
hostile: true,
|
||||
},
|
||||
],
|
||||
treasureHints: [],
|
||||
};
|
||||
|
||||
function createQuest() {
|
||||
const quest = buildQuestForEncounter({
|
||||
issuerNpcId: 'npc_scout',
|
||||
issuerNpcName: 'Scout Lin',
|
||||
roleText: 'tracker',
|
||||
scene: TEST_SCENE,
|
||||
worldType: TEST_WORLD,
|
||||
currentQuests: [],
|
||||
});
|
||||
assert.ok(quest);
|
||||
return quest;
|
||||
}
|
||||
|
||||
test('applyQuestSignal advances quest steps on the server side', () => {
|
||||
const quest = createQuest();
|
||||
const result = applyQuestSignal([quest], {
|
||||
kind: 'hostile_npc_defeated',
|
||||
sceneId: TEST_SCENE.id,
|
||||
hostileNpcId: 'wolf_alpha',
|
||||
});
|
||||
|
||||
assert.equal(result.updatedQuestIds.length, 1);
|
||||
assert.equal(result.updatedQuestIds[0], quest.id);
|
||||
assert.equal(result.updatedQuests[0]?.objective.kind, 'talk_to_npc');
|
||||
assert.equal(result.updatedQuests[0]?.status, 'active');
|
||||
});
|
||||
|
||||
test('turnInQuest rejects unfinished quests before reward-ready state', () => {
|
||||
const quest = createQuest();
|
||||
const result = turnInQuest([quest], quest.id);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
assert.equal(result.code, 'quest_not_ready_to_turn_in');
|
||||
});
|
||||
|
||||
test('turnInQuest marks ready quests as turned in after signal progression', () => {
|
||||
const quest = createQuest();
|
||||
const afterBattle = applyQuestSignal([quest], {
|
||||
kind: 'hostile_npc_defeated',
|
||||
sceneId: TEST_SCENE.id,
|
||||
hostileNpcId: 'wolf_alpha',
|
||||
});
|
||||
const afterTalk = applyQuestSignal(afterBattle.nextQuests, {
|
||||
kind: 'npc_talk_completed',
|
||||
npcId: 'npc_scout',
|
||||
});
|
||||
const turnInResult = turnInQuest(afterTalk.nextQuests, quest.id);
|
||||
|
||||
assert.equal(turnInResult.ok, true);
|
||||
if (!turnInResult.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
assert.equal(turnInResult.updatedQuests[0]?.status, 'turned_in');
|
||||
assert.equal(turnInResult.updatedQuests[0]?.completionNotified, true);
|
||||
});
|
||||
|
||||
test('acknowledgeQuestCompletion updates completion notification flag independently', () => {
|
||||
const quest = createQuest();
|
||||
const result = acknowledgeQuestCompletion([quest], quest.id);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
assert.equal(result.updatedQuests[0]?.completionNotified, true);
|
||||
});
|
||||
213
server-node/src/modules/quest/questProgressionService.ts
Normal file
213
server-node/src/modules/quest/questProgressionService.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
applyQuestProgressSignal,
|
||||
normalizeQuestLogEntries,
|
||||
} from '../../bridges/legacyQuestProgressBridge.js';
|
||||
|
||||
export type QuestLogEntry = Parameters<typeof normalizeQuestLogEntries>[0][number];
|
||||
export type QuestProgressSignal = Parameters<typeof applyQuestProgressSignal>[1];
|
||||
|
||||
type QuestMutationFailureCode = 'quest_not_found' | 'quest_not_ready_to_turn_in';
|
||||
|
||||
export type QuestMutationFailure = {
|
||||
ok: false;
|
||||
code: QuestMutationFailureCode;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type QuestMutationSuccess = {
|
||||
ok: true;
|
||||
nextQuests: QuestLogEntry[];
|
||||
updatedQuestIds: string[];
|
||||
updatedQuests: QuestLogEntry[];
|
||||
};
|
||||
|
||||
export type QuestMutationResult = QuestMutationFailure | QuestMutationSuccess;
|
||||
|
||||
function createFailure(
|
||||
code: QuestMutationFailureCode,
|
||||
message: string,
|
||||
): QuestMutationFailure {
|
||||
return {
|
||||
ok: false,
|
||||
code,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function collectUpdatedQuestIds(
|
||||
previous: QuestLogEntry[],
|
||||
next: QuestLogEntry[],
|
||||
): string[] {
|
||||
const previousById = new Map(previous.map((quest) => [quest.id, quest]));
|
||||
|
||||
return next
|
||||
.filter((quest) => {
|
||||
const previousQuest = previousById.get(quest.id);
|
||||
return JSON.stringify(previousQuest) !== JSON.stringify(quest);
|
||||
})
|
||||
.map((quest) => quest.id);
|
||||
}
|
||||
|
||||
function buildSuccess(
|
||||
previous: QuestLogEntry[],
|
||||
next: QuestLogEntry[],
|
||||
): QuestMutationSuccess {
|
||||
const updatedQuestIds = collectUpdatedQuestIds(previous, next);
|
||||
return {
|
||||
ok: true,
|
||||
nextQuests: next,
|
||||
updatedQuestIds,
|
||||
updatedQuests: next.filter((quest) => updatedQuestIds.includes(quest.id)),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeQuestEntries(quests: QuestLogEntry[]): QuestLogEntry[] {
|
||||
return normalizeQuestLogEntries(quests);
|
||||
}
|
||||
|
||||
function getQuestActiveStep(quest: QuestLogEntry) {
|
||||
if (!quest.steps?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (quest.activeStepId) {
|
||||
return quest.steps.find((step) => step.id === quest.activeStepId) ?? null;
|
||||
}
|
||||
|
||||
return quest.steps.find((step) => step.progress < step.requiredCount) ?? null;
|
||||
}
|
||||
|
||||
export function applyQuestSignal(
|
||||
quests: QuestLogEntry[],
|
||||
signal: QuestProgressSignal,
|
||||
): QuestMutationSuccess {
|
||||
const normalizedQuests = normalizeQuestEntries(quests);
|
||||
const nextQuests = applyQuestProgressSignal(normalizedQuests, signal);
|
||||
return buildSuccess(normalizedQuests, nextQuests);
|
||||
}
|
||||
|
||||
export function acknowledgeQuestCompletion(
|
||||
quests: QuestLogEntry[],
|
||||
questId: string,
|
||||
): QuestMutationResult {
|
||||
const normalizedQuests = normalizeQuestEntries(quests);
|
||||
const quest = findQuestById(normalizedQuests, questId);
|
||||
if (!quest) {
|
||||
return createFailure('quest_not_found', '未找到目标委托。');
|
||||
}
|
||||
|
||||
const nextQuests = markQuestCompletionNotified(normalizedQuests, questId);
|
||||
return buildSuccess(normalizedQuests, nextQuests);
|
||||
}
|
||||
|
||||
export function findQuestById(quests: QuestLogEntry[], questId: string) {
|
||||
return quests.find((quest) => quest.id === questId) ?? null;
|
||||
}
|
||||
|
||||
export function getQuestForIssuer(
|
||||
quests: QuestLogEntry[],
|
||||
issuerNpcId: string,
|
||||
) {
|
||||
return (
|
||||
normalizeQuestEntries(quests).find(
|
||||
(quest) =>
|
||||
quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in',
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function acceptQuest(
|
||||
quests: QuestLogEntry[],
|
||||
quest: QuestLogEntry,
|
||||
) {
|
||||
const normalizedQuests = normalizeQuestEntries(quests);
|
||||
if (findQuestById(normalizedQuests, quest.id)) {
|
||||
return normalizedQuests;
|
||||
}
|
||||
|
||||
return [...normalizedQuests, normalizeQuestEntries([quest])[0]!];
|
||||
}
|
||||
|
||||
export function buildQuestAcceptResultText(quest: QuestLogEntry) {
|
||||
const normalizedQuest = normalizeQuestEntries([quest])[0]!;
|
||||
const activeStep = getQuestActiveStep(normalizedQuest);
|
||||
return `${normalizedQuest.issuerNpcName} 正式把委托交到了你手上。${
|
||||
activeStep?.revealText ?? normalizedQuest.summary
|
||||
}`;
|
||||
}
|
||||
|
||||
export function buildQuestTurnInResultText(quest: QuestLogEntry) {
|
||||
const normalizedQuest = normalizeQuestEntries([quest])[0]!;
|
||||
const itemText = normalizedQuest.reward.items.map((item) => item.name).join('、');
|
||||
const intelText = normalizedQuest.reward.intel?.rumorText
|
||||
? `,并额外告诉了你一条消息:${normalizedQuest.reward.intel.rumorText}`
|
||||
: '';
|
||||
const storyHintText = normalizedQuest.reward.storyHint
|
||||
? ` ${normalizedQuest.reward.storyHint}`
|
||||
: '';
|
||||
|
||||
return `${normalizedQuest.issuerNpcName} 确认你已经完成委托,交给了你 ${normalizedQuest.reward.currency} 赏金和 ${itemText}${intelText}。${storyHintText}`;
|
||||
}
|
||||
|
||||
export function isQuestReadyToClaim(quest: QuestLogEntry) {
|
||||
const status = normalizeQuestEntries([quest])[0]!.status;
|
||||
return status === 'ready_to_turn_in' || status === 'completed';
|
||||
}
|
||||
|
||||
export function markQuestTurnedIn(
|
||||
quests: QuestLogEntry[],
|
||||
questId: string,
|
||||
) {
|
||||
return quests.map((quest) =>
|
||||
quest.id === questId
|
||||
? normalizeQuestEntries([
|
||||
{
|
||||
...quest,
|
||||
status: 'turned_in',
|
||||
completionNotified: true,
|
||||
steps: quest.steps?.map((step) => ({
|
||||
...step,
|
||||
progress: step.requiredCount,
|
||||
})),
|
||||
},
|
||||
])[0]!
|
||||
: normalizeQuestEntries([quest])[0]!,
|
||||
);
|
||||
}
|
||||
|
||||
export function markQuestCompletionNotified(
|
||||
quests: QuestLogEntry[],
|
||||
questId: string,
|
||||
) {
|
||||
return quests.map((quest) =>
|
||||
quest.id === questId
|
||||
? normalizeQuestEntries([
|
||||
{
|
||||
...quest,
|
||||
completionNotified: true,
|
||||
},
|
||||
])[0]!
|
||||
: normalizeQuestEntries([quest])[0]!,
|
||||
);
|
||||
}
|
||||
|
||||
export function turnInQuest(
|
||||
quests: QuestLogEntry[],
|
||||
questId: string,
|
||||
): QuestMutationResult {
|
||||
const normalizedQuests = normalizeQuestEntries(quests);
|
||||
const quest = findQuestById(normalizedQuests, questId);
|
||||
if (!quest) {
|
||||
return createFailure('quest_not_found', '未找到目标委托。');
|
||||
}
|
||||
|
||||
if (!isQuestReadyToClaim(quest)) {
|
||||
return createFailure(
|
||||
'quest_not_ready_to_turn_in',
|
||||
`${quest.title} 当前还不能交付结算。`,
|
||||
);
|
||||
}
|
||||
|
||||
const nextQuests = markQuestTurnedIn(normalizedQuests, questId);
|
||||
return buildSuccess(normalizedQuests, nextQuests);
|
||||
}
|
||||
84
server-node/src/modules/quest/questRuntimeSignalService.ts
Normal file
84
server-node/src/modules/quest/questRuntimeSignalService.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { RuntimeBattlePresentation } from '../../../../packages/shared/src/contracts/story.js';
|
||||
import {
|
||||
applyQuestSignal,
|
||||
normalizeQuestEntries,
|
||||
} from './questProgressionService.js';
|
||||
import {
|
||||
replaceRuntimeSessionRawGameState,
|
||||
type RuntimeSession,
|
||||
} from '../story/runtimeSession.js';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type RuntimeGameState = {
|
||||
currentScenePreset?: {
|
||||
id?: string | null;
|
||||
} | null;
|
||||
quests?: unknown[];
|
||||
};
|
||||
|
||||
function readSceneId(state: RuntimeGameState) {
|
||||
return state.currentScenePreset?.id ?? null;
|
||||
}
|
||||
|
||||
export function applyQuestSignalsForResolvedAction(params: {
|
||||
session: RuntimeSession;
|
||||
functionId: string;
|
||||
previousEncounter: RuntimeSession['currentEncounter'];
|
||||
battle?: RuntimeBattlePresentation | null;
|
||||
}) {
|
||||
const state = params.session.rawGameState as unknown as RuntimeGameState;
|
||||
const quests = normalizeQuestEntries(Array.isArray(state.quests) ? state.quests : []);
|
||||
if (quests.length <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mutation = null;
|
||||
|
||||
if (
|
||||
params.functionId === 'npc_chat' &&
|
||||
params.previousEncounter?.kind === 'npc'
|
||||
) {
|
||||
mutation = applyQuestSignal(quests, {
|
||||
kind: 'npc_talk_completed',
|
||||
npcId: params.previousEncounter.id,
|
||||
});
|
||||
} else if (
|
||||
params.battle?.outcome === 'victory' &&
|
||||
typeof params.battle.targetId === 'string' &&
|
||||
params.battle.targetId.trim()
|
||||
) {
|
||||
mutation = applyQuestSignal(quests, {
|
||||
kind: 'hostile_npc_defeated',
|
||||
sceneId: readSceneId(state),
|
||||
hostileNpcId: params.battle.targetId,
|
||||
});
|
||||
} else if (
|
||||
params.battle?.outcome === 'spar_complete' &&
|
||||
params.previousEncounter?.kind === 'npc'
|
||||
) {
|
||||
mutation = applyQuestSignal(quests, {
|
||||
kind: 'npc_spar_completed',
|
||||
npcId: params.previousEncounter.id,
|
||||
});
|
||||
} else if (
|
||||
params.functionId === 'treasure_inspect' ||
|
||||
params.functionId === 'treasure_secure'
|
||||
) {
|
||||
mutation = applyQuestSignal(quests, {
|
||||
kind: 'treasure_inspected',
|
||||
sceneId: readSceneId(state),
|
||||
});
|
||||
}
|
||||
|
||||
if (!mutation || mutation.updatedQuestIds.length <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
replaceRuntimeSessionRawGameState(
|
||||
params.session,
|
||||
{
|
||||
...state,
|
||||
quests: mutation.nextQuests,
|
||||
} as unknown as JsonRecord,
|
||||
);
|
||||
}
|
||||
242
server-node/src/modules/quest/questStoryActionService.ts
Normal file
242
server-node/src/modules/quest/questStoryActionService.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import type {
|
||||
RuntimeStoryActionRequest,
|
||||
RuntimeStoryPatch,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import { conflict, invalidRequest } from '../../errors.js';
|
||||
import {
|
||||
appendStoryEngineCarrierMemory,
|
||||
markNpcFirstMeaningfulContactResolved,
|
||||
} from '../../bridges/legacyNpcTask6Bridge.js';
|
||||
import {
|
||||
acceptQuest,
|
||||
addInventoryItems,
|
||||
buildQuestAcceptResultText,
|
||||
buildQuestForEncounter,
|
||||
buildQuestTurnInResultText,
|
||||
buildRelationState,
|
||||
getQuestForIssuer,
|
||||
incrementGameRuntimeStats,
|
||||
isQuestReadyToClaim,
|
||||
turnInQuest,
|
||||
} from './questTask6Bridge.js';
|
||||
import {
|
||||
replaceRuntimeSessionRawGameState,
|
||||
type RuntimeSession,
|
||||
} from '../story/runtimeSession.js';
|
||||
|
||||
const SUPPORTED_QUEST_STORY_FUNCTION_IDS = new Set<string>([
|
||||
'npc_quest_accept',
|
||||
'npc_quest_turn_in',
|
||||
]);
|
||||
|
||||
type QuestStoryResolution = {
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
patches: RuntimeStoryPatch[];
|
||||
};
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type RuntimeGameState = Parameters<typeof appendStoryEngineCarrierMemory>[0];
|
||||
type RuntimeNpcState = Parameters<
|
||||
typeof markNpcFirstMeaningfulContactResolved
|
||||
>[0];
|
||||
type RuntimeEncounter = {
|
||||
id?: string;
|
||||
kind?: 'npc' | 'treasure';
|
||||
npcAvatar?: string;
|
||||
npcName: string;
|
||||
npcDescription: string;
|
||||
context: string;
|
||||
hostile?: boolean;
|
||||
characterId?: string | null;
|
||||
monsterPresetId?: string | null;
|
||||
};
|
||||
|
||||
function getNpcEncounter(
|
||||
session: RuntimeSession,
|
||||
state: RuntimeGameState,
|
||||
): RuntimeEncounter | null {
|
||||
const rawEncounter = state.currentEncounter;
|
||||
if (!rawEncounter || rawEncounter.kind !== 'npc') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
npcAvatar: '',
|
||||
hostile: false,
|
||||
...rawEncounter,
|
||||
id: rawEncounter.id ?? session.currentEncounter?.id ?? rawEncounter.npcName,
|
||||
} satisfies RuntimeEncounter;
|
||||
}
|
||||
|
||||
function getNpcEncounterKey(encounter: RuntimeEncounter) {
|
||||
return encounter.id?.trim() || encounter.npcName;
|
||||
}
|
||||
|
||||
function readPayload(request: RuntimeStoryActionRequest) {
|
||||
return typeof request.action.payload === 'object' && request.action.payload
|
||||
? (request.action.payload as JsonRecord)
|
||||
: {};
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
function readQuestId(request: RuntimeStoryActionRequest) {
|
||||
const payload = readPayload(request);
|
||||
return readString(payload.questId) || readString(request.action.targetId);
|
||||
}
|
||||
|
||||
function ensureEncounterQuestContext(session: RuntimeSession) {
|
||||
const state = session.rawGameState as unknown as RuntimeGameState;
|
||||
const encounter = getNpcEncounter(session, state);
|
||||
if (!encounter) {
|
||||
throw conflict('当前不在可结算的 NPC 委托态。');
|
||||
}
|
||||
|
||||
const npcKey = getNpcEncounterKey(encounter);
|
||||
const npcState = state.npcStates?.[npcKey];
|
||||
if (!npcState) {
|
||||
throw conflict('当前 NPC 状态不存在,无法处理委托。');
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
encounter,
|
||||
npcKey,
|
||||
npcState,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveQuestAcceptAction(
|
||||
session: RuntimeSession,
|
||||
): QuestStoryResolution {
|
||||
const { state, encounter, npcKey, npcState } = ensureEncounterQuestContext(session);
|
||||
const quests = Array.isArray(state.quests) ? state.quests : [];
|
||||
const existingQuest = getQuestForIssuer(quests, npcKey);
|
||||
if (existingQuest) {
|
||||
throw conflict('当前角色已经有未结清的委托。');
|
||||
}
|
||||
|
||||
const quest = buildQuestForEncounter({
|
||||
issuerNpcId: npcKey,
|
||||
issuerNpcName: encounter.npcName,
|
||||
roleText: encounter.context,
|
||||
scene: state.currentScenePreset,
|
||||
worldType: state.worldType,
|
||||
currentQuests: quests.map((item) => ({
|
||||
id: item.id,
|
||||
issuerNpcId: item.issuerNpcId,
|
||||
status: item.status,
|
||||
})),
|
||||
});
|
||||
if (!quest) {
|
||||
throw conflict('当前场景缺少可落地的委托抓手,暂时无法接取任务。');
|
||||
}
|
||||
|
||||
const nextState = {
|
||||
...state,
|
||||
quests: acceptQuest(quests, quest),
|
||||
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, {
|
||||
questsAccepted: 1,
|
||||
}),
|
||||
npcStates: {
|
||||
...state.npcStates,
|
||||
[npcKey]: {
|
||||
...markNpcFirstMeaningfulContactResolved(npcState),
|
||||
},
|
||||
},
|
||||
} satisfies RuntimeGameState;
|
||||
|
||||
replaceRuntimeSessionRawGameState(
|
||||
session,
|
||||
nextState as unknown as JsonRecord,
|
||||
);
|
||||
|
||||
return {
|
||||
actionText: `接下${encounter.npcName}的委托`,
|
||||
resultText: buildQuestAcceptResultText(quest),
|
||||
patches: [],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveQuestTurnInAction(
|
||||
session: RuntimeSession,
|
||||
request: RuntimeStoryActionRequest,
|
||||
): QuestStoryResolution {
|
||||
const { state, encounter, npcKey, npcState } = ensureEncounterQuestContext(session);
|
||||
const quests = Array.isArray(state.quests) ? state.quests : [];
|
||||
const questId = readQuestId(request);
|
||||
const quest =
|
||||
(questId ? quests.find((item) => item.id === questId) : null) ??
|
||||
getQuestForIssuer(quests, npcKey);
|
||||
|
||||
if (!quest) {
|
||||
throw conflict('当前没有可交付的委托。');
|
||||
}
|
||||
|
||||
if (!isQuestReadyToClaim(quest)) {
|
||||
throw conflict('这份委托还没有达到可交付状态。');
|
||||
}
|
||||
|
||||
const turnInResult = turnInQuest(quests, quest.id);
|
||||
if (!turnInResult.ok) {
|
||||
throw conflict(turnInResult.message);
|
||||
}
|
||||
|
||||
const nextAffinity = npcState.affinity + quest.reward.affinityBonus;
|
||||
let nextState = {
|
||||
...state,
|
||||
quests: turnInResult.nextQuests,
|
||||
playerCurrency: state.playerCurrency + quest.reward.currency,
|
||||
playerInventory: addInventoryItems(state.playerInventory, quest.reward.items),
|
||||
npcStates: {
|
||||
...state.npcStates,
|
||||
[npcKey]: {
|
||||
...markNpcFirstMeaningfulContactResolved(npcState),
|
||||
affinity: nextAffinity,
|
||||
relationState: buildRelationState(nextAffinity),
|
||||
},
|
||||
},
|
||||
} satisfies RuntimeGameState;
|
||||
nextState = appendStoryEngineCarrierMemory(nextState, quest.reward.items);
|
||||
|
||||
replaceRuntimeSessionRawGameState(
|
||||
session,
|
||||
nextState as unknown as JsonRecord,
|
||||
);
|
||||
|
||||
return {
|
||||
actionText: `向${encounter.npcName}交付委托`,
|
||||
resultText: buildQuestTurnInResultText(quest),
|
||||
patches: [
|
||||
{
|
||||
type: 'npc_affinity_changed',
|
||||
npcId: npcKey,
|
||||
previousAffinity: npcState.affinity,
|
||||
nextAffinity,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function isSupportedQuestStoryFunctionId(functionId: string) {
|
||||
return SUPPORTED_QUEST_STORY_FUNCTION_IDS.has(functionId);
|
||||
}
|
||||
|
||||
export function resolveQuestStoryAction(
|
||||
session: RuntimeSession,
|
||||
request: RuntimeStoryActionRequest,
|
||||
): QuestStoryResolution {
|
||||
switch (request.action.functionId) {
|
||||
case 'npc_quest_accept':
|
||||
return resolveQuestAcceptAction(session);
|
||||
case 'npc_quest_turn_in':
|
||||
return resolveQuestTurnInAction(session, request);
|
||||
default:
|
||||
throw invalidRequest(
|
||||
`暂不支持的 Quest 动作:${request.action.functionId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
17
server-node/src/modules/quest/questTask6Bridge.ts
Normal file
17
server-node/src/modules/quest/questTask6Bridge.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Temporary bridge for legacy pure quest task6 action logic from src/**.
|
||||
export {
|
||||
addInventoryItems,
|
||||
buildRelationState,
|
||||
incrementGameRuntimeStats,
|
||||
} from '../runtime/runtimeStatePrimitives.js';
|
||||
export {
|
||||
buildQuestForEncounter,
|
||||
} from '../../bridges/legacyQuestProgressBridge.js';
|
||||
export {
|
||||
acceptQuest,
|
||||
buildQuestAcceptResultText,
|
||||
buildQuestTurnInResultText,
|
||||
getQuestForIssuer,
|
||||
isQuestReadyToClaim,
|
||||
turnInQuest,
|
||||
} from './questProgressionService.js';
|
||||
1246
server-node/src/modules/quest/runtimeQuestModule.ts
Normal file
1246
server-node/src/modules/quest/runtimeQuestModule.ts
Normal file
File diff suppressed because it is too large
Load Diff
2
server-node/src/modules/runtime-item/index.ts
Normal file
2
server-node/src/modules/runtime-item/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './runtimeItemResolutionService.js';
|
||||
export { generateRuntimeItemIntents } from '../../services/runtimeItemService.js';
|
||||
784
server-node/src/modules/runtime-item/runtimeItemModule.ts
Normal file
784
server-node/src/modules/runtime-item/runtimeItemModule.ts
Normal file
@@ -0,0 +1,784 @@
|
||||
import {
|
||||
RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES,
|
||||
RUNTIME_ITEM_TONE_VALUES,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
|
||||
export type RuntimeItemFunctionalBias =
|
||||
(typeof RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES)[number];
|
||||
export type RuntimeItemTone = (typeof RUNTIME_ITEM_TONE_VALUES)[number];
|
||||
|
||||
export type RuntimeRelationAnchor =
|
||||
| { type: 'npc'; npcName: string }
|
||||
| { type: 'scene'; sceneName: string }
|
||||
| { type: 'monster'; monsterName: string }
|
||||
| { type: 'quest'; questName: string }
|
||||
| { type: 'faction'; factionName: string }
|
||||
| { type: 'landmark'; landmarkName: string };
|
||||
|
||||
export type RuntimeItemPlan = {
|
||||
slot: string;
|
||||
itemKind: 'equipment' | 'consumable' | 'material' | 'relic' | 'quest';
|
||||
permanence: 'permanent' | 'timed' | 'resource';
|
||||
relationAnchor: RuntimeRelationAnchor;
|
||||
targetBuildDirection: string[];
|
||||
};
|
||||
|
||||
export type RuntimeItemAiPromptInput = {
|
||||
worldSummary: string;
|
||||
sceneSummary: string;
|
||||
encounterSummary: string;
|
||||
relatedNpcSummary: string;
|
||||
recentStorySummary: string;
|
||||
activeThreadSummary: string;
|
||||
generationChannel: string;
|
||||
playerBuildDirection: string[];
|
||||
playerBuildGaps: string[];
|
||||
desiredItemKind: RuntimeItemPlan['itemKind'];
|
||||
permanence: RuntimeItemPlan['permanence'];
|
||||
};
|
||||
|
||||
export type RuntimeItemAiIntent = {
|
||||
shortNameSeed: string;
|
||||
sourcePhrase: string;
|
||||
reasonToAppear: string;
|
||||
relationHooks: string[];
|
||||
desiredBuildTags: string[];
|
||||
desiredFunctionalBias: RuntimeItemFunctionalBias[];
|
||||
tone: RuntimeItemTone;
|
||||
visibleClue: string;
|
||||
witnessMark: string;
|
||||
unfinishedBusiness: string;
|
||||
hiddenHook: string;
|
||||
reactionHooks: string[];
|
||||
namingPattern: string;
|
||||
};
|
||||
|
||||
export type RuntimeItemStoryFingerprint = {
|
||||
relatedScarIds: string[];
|
||||
relatedThreadIds: string[];
|
||||
visibleClue: string;
|
||||
witnessMark: string;
|
||||
unresolvedQuestion: string;
|
||||
};
|
||||
|
||||
export type RuntimeItemInventory = {
|
||||
id: string;
|
||||
category: string;
|
||||
name: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
||||
tags: string[];
|
||||
equipmentSlotId?: string;
|
||||
buildProfile?: {
|
||||
role: string;
|
||||
tags: string[];
|
||||
synergy: string[];
|
||||
forgeRank: number;
|
||||
};
|
||||
statProfile?: {
|
||||
maxHpBonus?: number;
|
||||
outgoingDamageBonus?: number;
|
||||
incomingDamageMultiplier?: number;
|
||||
};
|
||||
useProfile?: {
|
||||
hpRestore: number;
|
||||
manaRestore: number;
|
||||
cooldownReduction: number;
|
||||
buildBuffs: Array<{
|
||||
id: string;
|
||||
sourceType: 'item';
|
||||
sourceId: string;
|
||||
name: string;
|
||||
tags: string[];
|
||||
durationTurns: number;
|
||||
}>;
|
||||
};
|
||||
runtimeMetadata?: {
|
||||
origin: 'ai_compiled' | 'procedural';
|
||||
generationChannel: string;
|
||||
seedKey: string;
|
||||
sourceReason: string;
|
||||
storyFingerprint: RuntimeItemStoryFingerprint;
|
||||
};
|
||||
};
|
||||
|
||||
export type DirectedRuntimeReward = {
|
||||
primaryItem: RuntimeItemInventory | null;
|
||||
supportItems: RuntimeItemInventory[];
|
||||
hp?: number;
|
||||
mana?: number;
|
||||
currency?: number;
|
||||
storyHint?: string;
|
||||
};
|
||||
|
||||
export type RuntimeItemGenerationContext = {
|
||||
worldType: string | null | undefined;
|
||||
customWorldProfile?: {
|
||||
name?: string;
|
||||
summary?: string;
|
||||
} | null;
|
||||
sceneId: string | null;
|
||||
sceneName: string | null;
|
||||
sceneDescription: string | null;
|
||||
treasureHints: string[];
|
||||
encounter: {
|
||||
id?: string;
|
||||
kind?: string;
|
||||
npcName: string;
|
||||
npcDescription?: string;
|
||||
npcAvatar?: string;
|
||||
context?: string;
|
||||
} | null;
|
||||
encounterNpcId: string | null;
|
||||
encounterNpcName: string | null;
|
||||
encounterContextText: string | null;
|
||||
relatedNpcState: {
|
||||
affinity?: number;
|
||||
} | null;
|
||||
relatedNpcNarrativeProfile: {
|
||||
publicMask?: string;
|
||||
visibleLine?: string;
|
||||
immediatePressure?: string;
|
||||
debtOrBurden?: string;
|
||||
contradiction?: string;
|
||||
taboo?: string;
|
||||
reactionHooks?: string[];
|
||||
relatedThreadIds?: string[];
|
||||
} | null;
|
||||
relatedScene: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
treasureHints?: string[];
|
||||
} | null;
|
||||
recentStorySummary: string;
|
||||
recentActions: string[];
|
||||
activeThreadIds: string[];
|
||||
playerCharacterId: string;
|
||||
playerBuildTags: string[];
|
||||
playerBuildGaps: string[];
|
||||
playerEquipmentTags: string[];
|
||||
generationChannel: string;
|
||||
};
|
||||
|
||||
type LooseContextInput = {
|
||||
worldType: string | null | undefined;
|
||||
customWorldProfile?: RuntimeItemGenerationContext['customWorldProfile'];
|
||||
scene?: RuntimeItemGenerationContext['relatedScene'];
|
||||
encounter?: RuntimeItemGenerationContext['encounter'];
|
||||
relatedNpcState?: RuntimeItemGenerationContext['relatedNpcState'];
|
||||
storyHistory?: Array<{ text: string }>;
|
||||
playerCharacterId?: string;
|
||||
playerBuildTags?: string[];
|
||||
playerEquipmentTags?: string[];
|
||||
generationChannel: string;
|
||||
};
|
||||
|
||||
function dedupeStrings(values: Array<string | null | undefined>) {
|
||||
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))];
|
||||
}
|
||||
|
||||
function sanitizeFragment(value: string | null | undefined, maxLength = 4) {
|
||||
return (value ?? '')
|
||||
.replace(/[^\u4e00-\u9fa5a-z0-9]/giu, '')
|
||||
.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function resolveAnchorLabel(anchor: RuntimeRelationAnchor) {
|
||||
switch (anchor.type) {
|
||||
case 'npc':
|
||||
return anchor.npcName;
|
||||
case 'scene':
|
||||
return anchor.sceneName;
|
||||
case 'monster':
|
||||
return anchor.monsterName;
|
||||
case 'quest':
|
||||
return anchor.questName;
|
||||
case 'faction':
|
||||
return anchor.factionName;
|
||||
default:
|
||||
return anchor.landmarkName;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRecentStoryLines(storyHistory: Array<{ text: string }> = []) {
|
||||
return storyHistory
|
||||
.slice(-4)
|
||||
.map((moment) => moment.text.trim())
|
||||
.filter(Boolean)
|
||||
.slice(-3);
|
||||
}
|
||||
|
||||
function buildRecentStorySummary(lines: string[]) {
|
||||
return lines.length > 0 ? lines.join(' / ') : '最近没有形成稳定的事件线索。';
|
||||
}
|
||||
|
||||
function derivePlayerBuildGaps(playerBuildTags: string[]) {
|
||||
const gapChecks = [
|
||||
{ id: 'survival_gap', tags: ['守御', '护体', '回复', '续战'] },
|
||||
{ id: 'mana_gap', tags: ['法力', '冷却', '护持', '过载'] },
|
||||
{ id: 'finisher_gap', tags: ['爆发', '重击', '追击', '压制'] },
|
||||
];
|
||||
|
||||
const tagSet = new Set(playerBuildTags);
|
||||
return gapChecks
|
||||
.filter((definition) => definition.tags.every((tag) => !tagSet.has(tag)))
|
||||
.map((definition) => definition.id)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
function buildRuntimeItemStoryFingerprint(params: {
|
||||
context: RuntimeItemGenerationContext;
|
||||
plan: RuntimeItemPlan;
|
||||
intent: RuntimeItemAiIntent;
|
||||
}) {
|
||||
const anchorKey = sanitizeFragment(resolveAnchorLabel(params.plan.relationAnchor), 6) || '旧痕';
|
||||
return {
|
||||
relatedScarIds: [`scar:${params.context.generationChannel}:${anchorKey}`],
|
||||
relatedThreadIds: params.context.activeThreadIds.slice(0, 2),
|
||||
visibleClue: params.intent.visibleClue,
|
||||
witnessMark: params.intent.witnessMark,
|
||||
unresolvedQuestion: params.intent.hiddenHook || params.intent.unfinishedBusiness,
|
||||
} satisfies RuntimeItemStoryFingerprint;
|
||||
}
|
||||
|
||||
function buildNarrativeName(
|
||||
plan: RuntimeItemPlan,
|
||||
intent: RuntimeItemAiIntent,
|
||||
index: number,
|
||||
) {
|
||||
const seed = intent.shortNameSeed || '旧痕';
|
||||
switch (plan.itemKind) {
|
||||
case 'equipment':
|
||||
return `${seed}${index === 0 ? '战符' : '护具'}`;
|
||||
case 'consumable':
|
||||
return `${seed}${intent.desiredFunctionalBias.includes('mana') ? '回息散' : '疗伤散'}`;
|
||||
case 'material':
|
||||
return `${seed}残材`;
|
||||
case 'quest':
|
||||
return `${seed}凭证`;
|
||||
default:
|
||||
return `${seed}遗物`;
|
||||
}
|
||||
}
|
||||
|
||||
function buildNarrativeDescription(params: {
|
||||
context: RuntimeItemGenerationContext;
|
||||
plan: RuntimeItemPlan;
|
||||
intent: RuntimeItemAiIntent;
|
||||
}) {
|
||||
const buildText = params.context.playerBuildTags.join('、') || '当前构筑';
|
||||
const anchorText = resolveAnchorLabel(params.plan.relationAnchor);
|
||||
return `${anchorText}把这件物件推到了你面前。它会围绕你的构筑 ${buildText} 发挥作用,原因是:${params.intent.reasonToAppear}`;
|
||||
}
|
||||
|
||||
function createRelationAnchor(
|
||||
context: RuntimeItemGenerationContext,
|
||||
index = 0,
|
||||
): RuntimeRelationAnchor {
|
||||
if (context.encounterNpcName) {
|
||||
return {
|
||||
type: 'npc',
|
||||
npcName: context.encounterNpcName,
|
||||
};
|
||||
}
|
||||
|
||||
if (context.sceneName) {
|
||||
return {
|
||||
type: 'scene',
|
||||
sceneName: context.sceneName,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'landmark',
|
||||
landmarkName: `遗址${index + 1}`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlanFromOptions(params: {
|
||||
context: RuntimeItemGenerationContext;
|
||||
index: number;
|
||||
fixedKinds?: RuntimeItemPlan['itemKind'][];
|
||||
fixedPermanence?: RuntimeItemPlan['permanence'][];
|
||||
}) {
|
||||
return {
|
||||
slot: `slot_${params.index + 1}`,
|
||||
itemKind: params.fixedKinds?.[params.index] ?? 'relic',
|
||||
permanence: params.fixedPermanence?.[params.index] ?? 'permanent',
|
||||
relationAnchor: createRelationAnchor(params.context, params.index),
|
||||
targetBuildDirection: params.context.playerBuildTags.slice(0, 3),
|
||||
} satisfies RuntimeItemPlan;
|
||||
}
|
||||
|
||||
function buildItemRarity(plan: RuntimeItemPlan) {
|
||||
if (plan.itemKind === 'equipment' || plan.itemKind === 'relic') {
|
||||
return 'rare' as const;
|
||||
}
|
||||
if (plan.itemKind === 'quest') {
|
||||
return 'epic' as const;
|
||||
}
|
||||
return 'uncommon' as const;
|
||||
}
|
||||
|
||||
function buildItemTags(
|
||||
plan: RuntimeItemPlan,
|
||||
intent: RuntimeItemAiIntent,
|
||||
context: RuntimeItemGenerationContext,
|
||||
) {
|
||||
return dedupeStrings([
|
||||
plan.itemKind,
|
||||
...intent.desiredBuildTags,
|
||||
...context.playerBuildTags.slice(0, 2),
|
||||
...intent.desiredFunctionalBias,
|
||||
]);
|
||||
}
|
||||
|
||||
function buildItemProfiles(
|
||||
itemId: string,
|
||||
plan: RuntimeItemPlan,
|
||||
intent: RuntimeItemAiIntent,
|
||||
context: RuntimeItemGenerationContext,
|
||||
) {
|
||||
if (plan.itemKind === 'equipment') {
|
||||
return {
|
||||
equipmentSlotId: 'weapon',
|
||||
buildProfile: {
|
||||
role: context.playerBuildTags[0] ?? '均衡',
|
||||
tags: buildItemTags(plan, intent, context).slice(0, 3),
|
||||
synergy: buildItemTags(plan, intent, context).slice(0, 3),
|
||||
forgeRank: 0,
|
||||
},
|
||||
statProfile: {
|
||||
maxHpBonus: intent.desiredFunctionalBias.includes('guard') ? 16 : 8,
|
||||
outgoingDamageBonus: intent.desiredFunctionalBias.includes('damage')
|
||||
? 0.12
|
||||
: 0.05,
|
||||
incomingDamageMultiplier: intent.desiredFunctionalBias.includes('guard')
|
||||
? 0.9
|
||||
: 0.96,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (plan.itemKind === 'consumable') {
|
||||
return {
|
||||
useProfile: {
|
||||
hpRestore: intent.desiredFunctionalBias.includes('heal') ? 12 : 0,
|
||||
manaRestore: intent.desiredFunctionalBias.includes('mana') ? 10 : 0,
|
||||
cooldownReduction: intent.desiredFunctionalBias.includes('cooldown') ? 1 : 0,
|
||||
buildBuffs: [
|
||||
{
|
||||
id: `${itemId}:buff`,
|
||||
sourceType: 'item' as const,
|
||||
sourceId: itemId,
|
||||
name: `${intent.shortNameSeed || '旧痕'}增益`,
|
||||
tags: buildItemTags(plan, intent, context).slice(0, 2),
|
||||
durationTurns: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function buildRuntimeInventoryItem(params: {
|
||||
context: RuntimeItemGenerationContext;
|
||||
plan: RuntimeItemPlan;
|
||||
intent: RuntimeItemAiIntent;
|
||||
seedKey: string;
|
||||
index: number;
|
||||
}) {
|
||||
const itemId = `${params.seedKey}:${params.index + 1}`;
|
||||
const storyFingerprint = buildRuntimeItemStoryFingerprint(params);
|
||||
const name = buildNarrativeName(params.plan, params.intent, params.index);
|
||||
|
||||
return {
|
||||
id: itemId,
|
||||
category:
|
||||
params.plan.itemKind === 'equipment'
|
||||
? '装备'
|
||||
: params.plan.itemKind === 'consumable'
|
||||
? '消耗品'
|
||||
: params.plan.itemKind === 'material'
|
||||
? '材料'
|
||||
: params.plan.itemKind === 'quest'
|
||||
? '凭证'
|
||||
: '遗物',
|
||||
name,
|
||||
description: buildNarrativeDescription(params),
|
||||
quantity: 1,
|
||||
rarity: buildItemRarity(params.plan),
|
||||
tags: buildItemTags(params.plan, params.intent, params.context),
|
||||
runtimeMetadata: {
|
||||
origin: 'ai_compiled' as const,
|
||||
generationChannel: params.context.generationChannel,
|
||||
seedKey: itemId,
|
||||
sourceReason: params.intent.reasonToAppear,
|
||||
storyFingerprint,
|
||||
},
|
||||
...buildItemProfiles(itemId, params.plan, params.intent, params.context),
|
||||
} satisfies RuntimeItemInventory;
|
||||
}
|
||||
|
||||
export function buildRuntimeItemAiPromptInput(
|
||||
context: RuntimeItemGenerationContext,
|
||||
plan: RuntimeItemPlan,
|
||||
) {
|
||||
return {
|
||||
worldSummary:
|
||||
context.customWorldProfile?.summary ?? context.worldType ?? '未知世界',
|
||||
sceneSummary: [context.sceneName, context.sceneDescription].filter(Boolean).join(' / '),
|
||||
encounterSummary: [context.encounterNpcName, context.encounterContextText]
|
||||
.filter(Boolean)
|
||||
.join(' / '),
|
||||
relatedNpcSummary: context.relatedNpcNarrativeProfile
|
||||
? `${context.encounterNpcName ?? '相关人物'}:公开面 ${
|
||||
context.relatedNpcNarrativeProfile.publicMask ?? '暂无'
|
||||
};当前压力 ${context.relatedNpcNarrativeProfile.immediatePressure ?? '暂无'}`
|
||||
: context.relatedNpcState
|
||||
? `${context.encounterNpcName ?? '相关人物'} 当前好感 ${context.relatedNpcState.affinity ?? 0}`
|
||||
: '暂无明确人物关系',
|
||||
recentStorySummary: context.recentStorySummary,
|
||||
activeThreadSummary: context.activeThreadIds.join('、'),
|
||||
generationChannel: context.generationChannel,
|
||||
playerBuildDirection: context.playerBuildTags,
|
||||
playerBuildGaps: context.playerBuildGaps,
|
||||
desiredItemKind: plan.itemKind,
|
||||
permanence: plan.permanence,
|
||||
} satisfies RuntimeItemAiPromptInput;
|
||||
}
|
||||
|
||||
export function buildRuntimeItemAiIntent(
|
||||
context: RuntimeItemGenerationContext,
|
||||
plan: RuntimeItemPlan,
|
||||
) {
|
||||
const anchorLabel = resolveAnchorLabel(plan.relationAnchor);
|
||||
const sourceSeed =
|
||||
sanitizeFragment(context.sceneName, 4) ||
|
||||
sanitizeFragment(context.customWorldProfile?.name, 4) ||
|
||||
sanitizeFragment(anchorLabel, 4) ||
|
||||
'旧誓';
|
||||
const functionalBias: RuntimeItemFunctionalBias[] = [];
|
||||
|
||||
if (plan.permanence === 'timed') {
|
||||
functionalBias.push(
|
||||
context.playerBuildGaps.includes('survival_gap') ? 'heal' : 'cooldown',
|
||||
);
|
||||
}
|
||||
if (context.playerBuildGaps.includes('mana_gap')) functionalBias.push('mana');
|
||||
if (context.playerBuildGaps.includes('survival_gap')) functionalBias.push('guard');
|
||||
if (
|
||||
functionalBias.length <= 0 ||
|
||||
context.playerBuildGaps.includes('finisher_gap') ||
|
||||
plan.itemKind === 'equipment'
|
||||
) {
|
||||
functionalBias.push('damage');
|
||||
}
|
||||
|
||||
return {
|
||||
shortNameSeed: sourceSeed,
|
||||
sourcePhrase: anchorLabel,
|
||||
reasonToAppear:
|
||||
context.generationChannel === 'monster_drop'
|
||||
? `${anchorLabel}倒下后,${context.sceneName ?? '这片战场'}里最值得带走的残留被翻了出来。`
|
||||
: `${anchorLabel}与最近局势把它推到了你面前。`,
|
||||
relationHooks: [context.encounterContextText ?? context.sceneName ?? anchorLabel, ...context.recentActions]
|
||||
.filter(Boolean)
|
||||
.slice(0, 2) as string[],
|
||||
desiredBuildTags: dedupeStrings([
|
||||
...plan.targetBuildDirection,
|
||||
...context.playerBuildTags.slice(0, 2),
|
||||
]).slice(0, 3),
|
||||
desiredFunctionalBias: [...new Set(functionalBias)].slice(0, 2),
|
||||
tone:
|
||||
context.generationChannel === 'monster_drop'
|
||||
? 'grim'
|
||||
: context.generationChannel === 'quest_reward'
|
||||
? 'ritual'
|
||||
: context.playerBuildGaps.includes('survival_gap')
|
||||
? 'survival'
|
||||
: 'martial',
|
||||
visibleClue:
|
||||
context.relatedNpcNarrativeProfile?.visibleLine ??
|
||||
`${anchorLabel}身上留下的旧痕`,
|
||||
witnessMark:
|
||||
context.relatedNpcNarrativeProfile?.debtOrBurden ??
|
||||
`${anchorLabel}尚未散尽的使用痕`,
|
||||
unfinishedBusiness:
|
||||
context.relatedNpcNarrativeProfile?.contradiction ??
|
||||
`${anchorLabel}背后还有没说完的问题`,
|
||||
hiddenHook:
|
||||
context.relatedNpcNarrativeProfile?.taboo ??
|
||||
`${anchorLabel}为什么会在此刻重新出现`,
|
||||
reactionHooks: [
|
||||
...(context.relatedNpcNarrativeProfile?.reactionHooks ?? []),
|
||||
...(context.activeThreadIds ?? []),
|
||||
].slice(0, 4),
|
||||
namingPattern:
|
||||
plan.itemKind === 'quest'
|
||||
? 'quest_evidence'
|
||||
: plan.itemKind === 'material'
|
||||
? 'scene_relic'
|
||||
: plan.relationAnchor.type === 'monster'
|
||||
? 'monster_trophy'
|
||||
: plan.relationAnchor.type === 'npc'
|
||||
? 'npc_relic'
|
||||
: 'faction_issue',
|
||||
} satisfies RuntimeItemAiIntent;
|
||||
}
|
||||
|
||||
function describeRelationAnchor(anchor: RuntimeRelationAnchor) {
|
||||
switch (anchor.type) {
|
||||
case 'npc':
|
||||
return `NPC:${anchor.npcName}`;
|
||||
case 'scene':
|
||||
return `场景:${anchor.sceneName}`;
|
||||
case 'monster':
|
||||
return `怪物:${anchor.monsterName}`;
|
||||
case 'quest':
|
||||
return `任务:${anchor.questName}`;
|
||||
case 'faction':
|
||||
return `势力:${anchor.factionName}`;
|
||||
default:
|
||||
return `地标:${anchor.landmarkName}`;
|
||||
}
|
||||
}
|
||||
|
||||
function describePlan(
|
||||
context: RuntimeItemGenerationContext,
|
||||
plan: RuntimeItemPlan,
|
||||
index: number,
|
||||
) {
|
||||
const promptInput = buildRuntimeItemAiPromptInput(context, plan);
|
||||
|
||||
return [
|
||||
`物品 ${index + 1}`,
|
||||
`- slot: ${plan.slot}`,
|
||||
`- 物品类型: ${promptInput.desiredItemKind}`,
|
||||
`- 持续性: ${promptInput.permanence}`,
|
||||
`- 关系锚点: ${describeRelationAnchor(plan.relationAnchor)}`,
|
||||
`- 世界摘要: ${promptInput.worldSummary}`,
|
||||
`- 场景摘要: ${promptInput.sceneSummary || '无'}`,
|
||||
`- 遭遇摘要: ${promptInput.encounterSummary || '无'}`,
|
||||
`- 相关人物: ${promptInput.relatedNpcSummary}`,
|
||||
`- 当前激活线程: ${promptInput.activeThreadSummary || '暂无'}`,
|
||||
`- 近期剧情: ${promptInput.recentStorySummary}`,
|
||||
`- 玩家当前 build: ${promptInput.playerBuildDirection.join('、') || '均衡'}`,
|
||||
`- 玩家待补缺口: ${promptInput.playerBuildGaps.join('、') || '无明显缺口'}`,
|
||||
`- 本次目标方向: ${plan.targetBuildDirection.join('、') || '均衡'}`,
|
||||
].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');
|
||||
}
|
||||
|
||||
function buildBaseRuntimeContext(params: {
|
||||
worldType: string | null | undefined;
|
||||
customWorldProfile?: RuntimeItemGenerationContext['customWorldProfile'];
|
||||
scene?: RuntimeItemGenerationContext['relatedScene'];
|
||||
encounter?: RuntimeItemGenerationContext['encounter'];
|
||||
relatedNpcState?: RuntimeItemGenerationContext['relatedNpcState'];
|
||||
storyHistory?: Array<{ text: string }>;
|
||||
playerCharacterId?: string;
|
||||
playerBuildTags?: string[];
|
||||
playerEquipmentTags?: string[];
|
||||
generationChannel: string;
|
||||
}) {
|
||||
const recentStoryLines = buildRecentStoryLines(params.storyHistory);
|
||||
const activeThreadIds = dedupeStrings(
|
||||
params.encounter?.kind === 'npc' && params.encounter?.id
|
||||
? [`thread:${params.encounter.id}`]
|
||||
: params.scene?.id
|
||||
? [`thread:${params.scene.id}`]
|
||||
: [],
|
||||
).slice(0, 3);
|
||||
|
||||
return {
|
||||
worldType: params.worldType,
|
||||
customWorldProfile: params.customWorldProfile ?? null,
|
||||
sceneId: params.scene?.id ?? null,
|
||||
sceneName: params.scene?.name ?? null,
|
||||
sceneDescription: params.scene?.description ?? null,
|
||||
treasureHints: [...(params.scene?.treasureHints ?? [])],
|
||||
encounter: params.encounter ?? null,
|
||||
encounterNpcId:
|
||||
params.encounter?.id ?? params.encounter?.npcName ?? null,
|
||||
encounterNpcName: params.encounter?.npcName ?? null,
|
||||
encounterContextText: params.encounter?.context ?? null,
|
||||
relatedNpcState: params.relatedNpcState ?? null,
|
||||
relatedNpcNarrativeProfile: null,
|
||||
relatedScene: params.scene ?? null,
|
||||
recentStorySummary: buildRecentStorySummary(recentStoryLines),
|
||||
recentActions: recentStoryLines,
|
||||
activeThreadIds,
|
||||
playerCharacterId: params.playerCharacterId ?? 'runtime-loose-player',
|
||||
playerBuildTags: params.playerBuildTags ?? [],
|
||||
playerBuildGaps: derivePlayerBuildGaps(params.playerBuildTags ?? []),
|
||||
playerEquipmentTags: params.playerEquipmentTags ?? [],
|
||||
generationChannel: params.generationChannel,
|
||||
} satisfies RuntimeItemGenerationContext;
|
||||
}
|
||||
|
||||
export function buildLooseRuntimeItemGenerationContext(params: LooseContextInput) {
|
||||
return buildBaseRuntimeContext(params);
|
||||
}
|
||||
|
||||
export function buildQuestRuntimeItemGenerationContext(params: {
|
||||
context: {
|
||||
worldType?: string | null;
|
||||
customWorldProfile?: RuntimeItemGenerationContext['customWorldProfile'];
|
||||
currentSceneId?: string | null;
|
||||
currentSceneName?: string | null;
|
||||
currentSceneDescription?: string | null;
|
||||
issuerAffinity?: number | null;
|
||||
recentStoryMoments?: Array<{ text: string }>;
|
||||
playerCharacter?: { id: string } | null;
|
||||
};
|
||||
generationChannel?: string;
|
||||
issuerNpcId: string;
|
||||
issuerNpcName: string;
|
||||
roleText: string;
|
||||
scene?: RuntimeItemGenerationContext['relatedScene'];
|
||||
}) {
|
||||
const { context, issuerNpcId, issuerNpcName, roleText } = params;
|
||||
|
||||
return buildBaseRuntimeContext({
|
||||
worldType: context.worldType ?? null,
|
||||
customWorldProfile: context.customWorldProfile ?? null,
|
||||
scene:
|
||||
params.scene ??
|
||||
(context.currentSceneName
|
||||
? {
|
||||
id: context.currentSceneId ?? '',
|
||||
name: context.currentSceneName,
|
||||
description: context.currentSceneDescription ?? '',
|
||||
treasureHints: [],
|
||||
}
|
||||
: null),
|
||||
encounter: {
|
||||
id: issuerNpcId,
|
||||
kind: 'npc',
|
||||
npcName: issuerNpcName,
|
||||
npcDescription: roleText,
|
||||
npcAvatar: '',
|
||||
context: roleText,
|
||||
},
|
||||
relatedNpcState:
|
||||
context.issuerAffinity == null
|
||||
? null
|
||||
: {
|
||||
affinity: context.issuerAffinity,
|
||||
},
|
||||
storyHistory: context.recentStoryMoments ?? [],
|
||||
playerCharacterId: context.playerCharacter?.id ?? 'quest-player',
|
||||
generationChannel: params.generationChannel ?? 'quest_reward',
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDirectedRuntimeReward(
|
||||
context: RuntimeItemGenerationContext,
|
||||
options: {
|
||||
seedKey: string;
|
||||
itemCount?: number;
|
||||
fixedKinds?: RuntimeItemPlan['itemKind'][];
|
||||
fixedPermanence?: RuntimeItemPlan['permanence'][];
|
||||
baseHp?: number;
|
||||
baseMana?: number;
|
||||
baseCurrency?: number;
|
||||
storyHint?: string;
|
||||
},
|
||||
) {
|
||||
const itemCount = Math.max(1, options.itemCount ?? 2);
|
||||
const items = Array.from({ length: itemCount }, (_, index) => {
|
||||
const plan = buildPlanFromOptions({
|
||||
context,
|
||||
index,
|
||||
fixedKinds: options.fixedKinds,
|
||||
fixedPermanence: options.fixedPermanence,
|
||||
});
|
||||
const intent = buildRuntimeItemAiIntent(context, plan);
|
||||
return buildRuntimeInventoryItem({
|
||||
context,
|
||||
plan,
|
||||
intent,
|
||||
seedKey: options.seedKey,
|
||||
index,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
primaryItem: items[0] ?? null,
|
||||
supportItems: items.slice(1),
|
||||
hp: options.baseHp ?? 0,
|
||||
mana: options.baseMana ?? 0,
|
||||
currency: options.baseCurrency ?? 0,
|
||||
storyHint:
|
||||
options.storyHint ??
|
||||
(items[0]
|
||||
? `${items[0].name} 先露出的是“${
|
||||
items[0].runtimeMetadata?.storyFingerprint.visibleClue ?? '旧痕'
|
||||
}”。`
|
||||
: '你得到了一件与当前局势相关的物品。'),
|
||||
} satisfies DirectedRuntimeReward;
|
||||
}
|
||||
|
||||
export function flattenDirectedRuntimeRewardItems(reward: DirectedRuntimeReward) {
|
||||
return [
|
||||
...(reward.primaryItem ? [reward.primaryItem] : []),
|
||||
...reward.supportItems,
|
||||
];
|
||||
}
|
||||
|
||||
export function buildRuntimeInventoryStock(
|
||||
context: RuntimeItemGenerationContext,
|
||||
options: Parameters<typeof buildDirectedRuntimeReward>[1],
|
||||
) {
|
||||
return flattenDirectedRuntimeRewardItems(
|
||||
buildDirectedRuntimeReward(context, options),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildLooseRuntimeItemGenerationContext,
|
||||
buildQuestRuntimeItemGenerationContext,
|
||||
} from '../../bridges/legacyRuntimeItemResolutionBridge.js';
|
||||
import {
|
||||
resolveDirectedReward,
|
||||
resolveRuntimeInventoryStock,
|
||||
} from './runtimeItemResolutionService.js';
|
||||
|
||||
const TEST_WUXIA_WORLD = 'WUXIA' as Parameters<
|
||||
typeof buildLooseRuntimeItemGenerationContext
|
||||
>[0]['worldType'];
|
||||
const TEST_XIANXIA_WORLD = 'XIANXIA' as NonNullable<
|
||||
Parameters<typeof buildQuestRuntimeItemGenerationContext>[0]['context']['worldType']
|
||||
>;
|
||||
|
||||
test('resolveDirectedReward returns flattened runtime reward items on the server side', () => {
|
||||
const context = buildLooseRuntimeItemGenerationContext({
|
||||
worldType: TEST_WUXIA_WORLD,
|
||||
scene: {
|
||||
id: 'scene-ruins',
|
||||
name: '断碑古道',
|
||||
description: '碎碑与旧誓散落在路旁。',
|
||||
treasureHints: ['残匣', '旧祭火'],
|
||||
},
|
||||
encounter: {
|
||||
id: 'treasure-altar',
|
||||
kind: 'treasure',
|
||||
npcName: '断誓秘匣',
|
||||
npcDescription: '匣盖上留着未熄的旧印。',
|
||||
npcAvatar: '',
|
||||
context: '古道祭坛',
|
||||
},
|
||||
playerCharacterId: 'hero',
|
||||
playerBuildTags: ['快剑', '追击'],
|
||||
generationChannel: 'treasure',
|
||||
});
|
||||
|
||||
const result = resolveDirectedReward(context, {
|
||||
seedKey: 'task6:treasure',
|
||||
fixedKinds: ['relic', 'consumable'],
|
||||
fixedPermanence: ['permanent', 'timed'],
|
||||
itemCount: 2,
|
||||
});
|
||||
|
||||
assert.equal(result.items.length, 2);
|
||||
assert.equal(
|
||||
result.reward.primaryItem?.runtimeMetadata?.generationChannel,
|
||||
'treasure',
|
||||
);
|
||||
assert.equal(result.items[0]?.id, result.reward.primaryItem?.id);
|
||||
assert.ok(result.reward.primaryItem?.description?.includes('构筑'));
|
||||
});
|
||||
|
||||
test('resolveRuntimeInventoryStock builds quest-flavored stock without browser fallback', () => {
|
||||
const context = buildQuestRuntimeItemGenerationContext({
|
||||
context: {
|
||||
worldType: TEST_XIANXIA_WORLD,
|
||||
currentSceneId: 'scene-cloud',
|
||||
currentSceneName: '云阙旧渡',
|
||||
currentSceneDescription: '旧渡口残留着灵潮和巡守痕迹。',
|
||||
issuerNpcId: 'npc-issuer',
|
||||
issuerNpcName: '巡守使',
|
||||
issuerNpcContext: '巡守',
|
||||
issuerAffinity: 24,
|
||||
recentStoryMoments: [],
|
||||
playerCharacter: null,
|
||||
},
|
||||
issuerNpcId: 'npc-issuer',
|
||||
issuerNpcName: '巡守使',
|
||||
roleText: '巡守',
|
||||
scene: {
|
||||
id: 'scene-cloud',
|
||||
name: '云阙旧渡',
|
||||
description: '旧渡口残留着灵潮和巡守痕迹。',
|
||||
treasureHints: ['旧印'],
|
||||
},
|
||||
});
|
||||
|
||||
const items = resolveRuntimeInventoryStock(context, {
|
||||
seedKey: 'task6:quest',
|
||||
fixedKinds: ['equipment', 'consumable'],
|
||||
fixedPermanence: ['permanent', 'timed'],
|
||||
itemCount: 2,
|
||||
});
|
||||
|
||||
assert.equal(items.length, 2);
|
||||
assert.equal(
|
||||
items.every((item) => item.runtimeMetadata?.generationChannel === 'quest_reward'),
|
||||
true,
|
||||
);
|
||||
assert.equal(items.some((item) => Boolean(item.buildProfile || item.useProfile)), true);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
buildDirectedRuntimeReward,
|
||||
buildRuntimeInventoryStock,
|
||||
flattenDirectedRuntimeRewardItems,
|
||||
} from '../../bridges/legacyRuntimeItemResolutionBridge.js';
|
||||
|
||||
export type RuntimeItemGenerationContext = Parameters<
|
||||
typeof buildDirectedRuntimeReward
|
||||
>[0];
|
||||
export type RuntimeRewardOptions = Parameters<
|
||||
typeof buildDirectedRuntimeReward
|
||||
>[1];
|
||||
export type DirectedRuntimeReward = ReturnType<typeof buildDirectedRuntimeReward>;
|
||||
export type ResolvedRuntimeRewardItem = ReturnType<
|
||||
typeof buildRuntimeInventoryStock
|
||||
>[number];
|
||||
|
||||
export type RuntimeRewardResolution = {
|
||||
reward: DirectedRuntimeReward;
|
||||
items: ResolvedRuntimeRewardItem[];
|
||||
};
|
||||
|
||||
export function resolveDirectedReward(
|
||||
context: RuntimeItemGenerationContext,
|
||||
options: RuntimeRewardOptions,
|
||||
): RuntimeRewardResolution {
|
||||
const reward = buildDirectedRuntimeReward(context, options);
|
||||
return {
|
||||
reward,
|
||||
items: flattenDirectedRuntimeRewardItems(reward),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRuntimeInventoryStock(
|
||||
context: RuntimeItemGenerationContext,
|
||||
options: RuntimeRewardOptions,
|
||||
): ResolvedRuntimeRewardItem[] {
|
||||
return buildRuntimeInventoryStock(context, options);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
buildDirectedRuntimeReward,
|
||||
buildLooseRuntimeItemGenerationContext,
|
||||
flattenDirectedRuntimeRewardItems,
|
||||
} from './runtimeItemModule.js';
|
||||
|
||||
type TreasureInteractionAction = 'inspect' | 'leave' | 'secure';
|
||||
|
||||
type RuntimeStateLike = {
|
||||
worldType: string | null | undefined;
|
||||
currentScenePreset?: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
treasureHints?: string[];
|
||||
} | null;
|
||||
currentEncounter?: {
|
||||
id?: string;
|
||||
kind?: string;
|
||||
npcName: string;
|
||||
npcDescription?: string;
|
||||
npcAvatar?: string;
|
||||
context?: string;
|
||||
} | null;
|
||||
playerCharacter?: {
|
||||
id: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type RuntimeEncounterLike = NonNullable<RuntimeStateLike['currentEncounter']>;
|
||||
|
||||
export type TreasureReward = {
|
||||
items: ReturnType<typeof flattenDirectedRuntimeRewardItems>;
|
||||
hp: number;
|
||||
mana: number;
|
||||
currency: number;
|
||||
storyHint?: string;
|
||||
};
|
||||
|
||||
export function resolveTreasureReward(
|
||||
state: RuntimeStateLike,
|
||||
encounter: RuntimeEncounterLike,
|
||||
action: TreasureInteractionAction,
|
||||
) {
|
||||
const context = buildLooseRuntimeItemGenerationContext({
|
||||
worldType: state.worldType,
|
||||
scene: state.currentScenePreset ?? null,
|
||||
encounter,
|
||||
playerCharacterId: state.playerCharacter?.id ?? 'treasure-player',
|
||||
generationChannel: 'treasure',
|
||||
});
|
||||
const directed = buildDirectedRuntimeReward(context, {
|
||||
seedKey: `treasure:${encounter.id ?? encounter.npcName}:${action}`,
|
||||
variant: action,
|
||||
itemCount: 2,
|
||||
fixedKinds:
|
||||
action === 'inspect' ? ['relic', 'consumable'] : ['relic', 'material'],
|
||||
fixedPermanence:
|
||||
action === 'inspect' ? ['permanent', 'timed'] : ['permanent', 'resource'],
|
||||
baseHp: action === 'inspect' ? 10 : 0,
|
||||
baseMana: action === 'inspect' ? 12 : 0,
|
||||
baseCurrency:
|
||||
action === 'inspect'
|
||||
? state.worldType === 'XIANXIA'
|
||||
? 34
|
||||
: 48
|
||||
: state.worldType === 'XIANXIA'
|
||||
? 22
|
||||
: 30,
|
||||
storyHint: `${encounter.npcName}里藏着与你当前构筑和现场线索贴合的战利品。`,
|
||||
} as Parameters<typeof buildDirectedRuntimeReward>[1]);
|
||||
|
||||
return {
|
||||
items: flattenDirectedRuntimeRewardItems(directed),
|
||||
hp: directed.hp ?? 0,
|
||||
mana: directed.mana ?? 0,
|
||||
currency: directed.currency ?? 0,
|
||||
storyHint: directed.storyHint,
|
||||
} satisfies TreasureReward;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type {
|
||||
RuntimeStoryActionRequest,
|
||||
RuntimeStoryPatch,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import { conflict, invalidRequest } from '../../errors.js';
|
||||
import {
|
||||
addInventoryItems,
|
||||
appendStoryEngineCarrierMemory,
|
||||
} from '../../bridges/legacyNpcTask6Bridge.js';
|
||||
import {
|
||||
buildTreasureResultText,
|
||||
resolveTreasureReward,
|
||||
} from '../../bridges/legacyTreasureRuntimeBridge.js';
|
||||
import { buildBuildToast } from '../inventory/inventoryStoryActionService.js';
|
||||
import {
|
||||
replaceRuntimeSessionRawGameState,
|
||||
type RuntimeSession,
|
||||
} from '../story/runtimeSession.js';
|
||||
|
||||
const SUPPORTED_TREASURE_STORY_FUNCTION_IDS = new Set<string>([
|
||||
'treasure_inspect',
|
||||
'treasure_leave',
|
||||
'treasure_secure',
|
||||
]);
|
||||
|
||||
type TreasureStoryResolution = {
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
patches: RuntimeStoryPatch[];
|
||||
toast?: string | null;
|
||||
};
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type RuntimeGameState = Parameters<typeof resolveTreasureReward>[0];
|
||||
type RuntimeEncounter = Parameters<typeof resolveTreasureReward>[1];
|
||||
|
||||
function resolveTreasureAction(functionId: string) {
|
||||
switch (functionId) {
|
||||
case 'treasure_secure':
|
||||
return 'secure';
|
||||
case 'treasure_inspect':
|
||||
return 'inspect';
|
||||
case 'treasure_leave':
|
||||
return 'leave';
|
||||
default:
|
||||
throw invalidRequest(`暂不支持的 Treasure 动作:${functionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getTreasureEncounter(
|
||||
session: RuntimeSession,
|
||||
state: RuntimeGameState,
|
||||
): RuntimeEncounter | null {
|
||||
const rawEncounter = state.currentEncounter;
|
||||
if (!rawEncounter || rawEncounter.kind !== 'treasure') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
npcAvatar: '',
|
||||
hostile: false,
|
||||
...rawEncounter,
|
||||
id: rawEncounter.id ?? session.currentEncounter?.id ?? rawEncounter.npcName,
|
||||
} satisfies RuntimeEncounter;
|
||||
}
|
||||
|
||||
export function isSupportedTreasureStoryFunctionId(functionId: string) {
|
||||
return SUPPORTED_TREASURE_STORY_FUNCTION_IDS.has(functionId);
|
||||
}
|
||||
|
||||
export function resolveTreasureStoryAction(
|
||||
session: RuntimeSession,
|
||||
request: RuntimeStoryActionRequest,
|
||||
): TreasureStoryResolution {
|
||||
const state = session.rawGameState as unknown as RuntimeGameState;
|
||||
const encounter = getTreasureEncounter(session, state);
|
||||
if (!encounter) {
|
||||
throw conflict('当前没有可结算的宝藏遭遇。');
|
||||
}
|
||||
|
||||
const action = resolveTreasureAction(request.action.functionId);
|
||||
const reward =
|
||||
action === 'leave' ? null : resolveTreasureReward(state, encounter, action);
|
||||
|
||||
let nextState = {
|
||||
...state,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: state.animationState,
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: reward
|
||||
? Math.min(state.playerMaxHp, state.playerHp + reward.hp)
|
||||
: state.playerHp,
|
||||
playerMana: reward
|
||||
? Math.min(state.playerMaxMana, state.playerMana + reward.mana)
|
||||
: state.playerMana,
|
||||
playerCurrency: reward
|
||||
? state.playerCurrency + reward.currency
|
||||
: state.playerCurrency,
|
||||
playerInventory: reward
|
||||
? addInventoryItems(state.playerInventory, reward.items)
|
||||
: state.playerInventory,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
} satisfies RuntimeGameState;
|
||||
if (reward) {
|
||||
nextState = appendStoryEngineCarrierMemory(nextState, reward.items);
|
||||
}
|
||||
|
||||
replaceRuntimeSessionRawGameState(
|
||||
session,
|
||||
nextState as unknown as JsonRecord,
|
||||
);
|
||||
|
||||
return {
|
||||
actionText:
|
||||
action === 'leave'
|
||||
? '先记下位置'
|
||||
: action === 'inspect'
|
||||
? '仔细检查'
|
||||
: '直接收取',
|
||||
resultText: buildTreasureResultText(
|
||||
encounter,
|
||||
action,
|
||||
reward ?? undefined,
|
||||
state.worldType,
|
||||
),
|
||||
patches: [],
|
||||
toast: reward ? buildBuildToast(nextState) : null,
|
||||
};
|
||||
}
|
||||
211
server-node/src/modules/runtime/runtimeBuildModule.ts
Normal file
211
server-node/src/modules/runtime/runtimeBuildModule.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
getEquipmentBonuses,
|
||||
type RuntimeEquipmentLoadout,
|
||||
} from './runtimeEquipmentModule.js';
|
||||
|
||||
type RuntimeCharacterLike = {
|
||||
attributes: {
|
||||
strength: number;
|
||||
agility: number;
|
||||
intelligence: number;
|
||||
spirit: number;
|
||||
};
|
||||
};
|
||||
|
||||
type RuntimeBuildBuff = {
|
||||
id: string;
|
||||
name: string;
|
||||
tags: string[];
|
||||
durationTurns: number;
|
||||
};
|
||||
|
||||
type RuntimeInventoryItemLike = {
|
||||
buildProfile?: {
|
||||
role: string;
|
||||
tags: string[];
|
||||
synergy: string[];
|
||||
forgeRank: number;
|
||||
};
|
||||
};
|
||||
|
||||
type RuntimeGameStateLike<TItem extends RuntimeInventoryItemLike = RuntimeInventoryItemLike> = {
|
||||
playerEquipment: RuntimeEquipmentLoadout<TItem>;
|
||||
activeBuildBuffs?: RuntimeBuildBuff[];
|
||||
playerCharacter?: RuntimeCharacterLike | null;
|
||||
};
|
||||
|
||||
export type BuildContributionRow = {
|
||||
label: string;
|
||||
source: 'buff' | 'weapon' | 'armor' | 'relic' | 'character';
|
||||
fitScore: number;
|
||||
sourceCoefficient: number;
|
||||
bonusDelta: number;
|
||||
attributeSimilarities: Record<string, number>;
|
||||
attributeWeights: Record<string, number>;
|
||||
attributeContributions: Record<string, number>;
|
||||
attributeModifierDeltas: Record<string, number>;
|
||||
};
|
||||
|
||||
export type BuildDamageBreakdown = {
|
||||
tags: string[];
|
||||
baseTagCount: number;
|
||||
buildDamageBonus: number;
|
||||
buildDamageMultiplier: number;
|
||||
rows: BuildContributionRow[];
|
||||
};
|
||||
|
||||
export type OutgoingDamageResult = {
|
||||
damage: number;
|
||||
isCritical: boolean;
|
||||
critChance: number;
|
||||
critDamageMultiplier: number;
|
||||
attackPowerMultiplier: number;
|
||||
};
|
||||
|
||||
function roundNumber(value: number, digits = 4) {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function hashSeed(seed: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < seed.length; index += 1) {
|
||||
hash = (hash * 31 + seed.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
export function appendBuildBuffs<TBuff extends RuntimeBuildBuff>(
|
||||
baseBuffs: TBuff[] | null | undefined,
|
||||
additions: TBuff[] | null | undefined,
|
||||
) {
|
||||
const merged = new Map<string, TBuff>();
|
||||
|
||||
[...(baseBuffs ?? []), ...(additions ?? [])].forEach((buff) => {
|
||||
const existing = merged.get(buff.id);
|
||||
if (!existing || (buff.durationTurns ?? 0) >= (existing.durationTurns ?? 0)) {
|
||||
merged.set(buff.id, {
|
||||
...buff,
|
||||
tags: [...new Set(buff.tags.map((tag) => tag.trim()).filter(Boolean))],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return [...merged.values()].filter(
|
||||
(buff) => buff.tags.length > 0 && buff.durationTurns > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function collectBuildTags<TItem extends RuntimeInventoryItemLike>(
|
||||
state: RuntimeGameStateLike<TItem>,
|
||||
character: RuntimeCharacterLike,
|
||||
) {
|
||||
const tags = new Set<string>();
|
||||
state.activeBuildBuffs
|
||||
?.filter((buff) => (buff.durationTurns ?? 0) > 0)
|
||||
.forEach((buff) => buff.tags.forEach((tag) => tags.add(tag)));
|
||||
|
||||
(['weapon', 'armor', 'relic'] as const).forEach((slot) => {
|
||||
const item = state.playerEquipment[slot];
|
||||
item?.buildProfile?.tags?.forEach((tag) => tags.add(tag));
|
||||
if (item?.buildProfile?.role) {
|
||||
tags.add(item.buildProfile.role);
|
||||
}
|
||||
});
|
||||
|
||||
if (character.attributes.agility >= 10) tags.add('快剑');
|
||||
if (character.attributes.strength >= 10) tags.add('重击');
|
||||
if (character.attributes.spirit >= 10) tags.add('续战');
|
||||
if (character.attributes.intelligence >= 8) tags.add('法力');
|
||||
|
||||
return [...tags].filter(Boolean).slice(0, 8);
|
||||
}
|
||||
|
||||
export function getPlayerBuildDamageBreakdown<
|
||||
TState extends RuntimeGameStateLike<TItem>,
|
||||
TItem extends RuntimeInventoryItemLike,
|
||||
>(state: TState, character: RuntimeCharacterLike) {
|
||||
const tags = collectBuildTags(state, character);
|
||||
const rows = tags.map((tag, index) => {
|
||||
const bonusDelta = roundNumber(0.03 + Math.min(index, 3) * 0.01, 4);
|
||||
return {
|
||||
label: tag,
|
||||
source: index === 0 ? 'buff' : 'weapon',
|
||||
fitScore: roundNumber(0.6 + Math.max(0, 3 - index) * 0.08, 4),
|
||||
sourceCoefficient: 1,
|
||||
bonusDelta,
|
||||
attributeSimilarities: {},
|
||||
attributeWeights: {},
|
||||
attributeContributions: {},
|
||||
attributeModifierDeltas: {},
|
||||
} satisfies BuildContributionRow;
|
||||
});
|
||||
|
||||
const buildDamageBonus = roundNumber(
|
||||
clamp(rows.reduce((sum, row) => sum + row.bonusDelta, 0), 0, 0.6),
|
||||
4,
|
||||
);
|
||||
|
||||
return {
|
||||
tags,
|
||||
baseTagCount: tags.length,
|
||||
buildDamageBonus,
|
||||
buildDamageMultiplier: roundNumber(1 + buildDamageBonus, 4),
|
||||
rows,
|
||||
} satisfies BuildDamageBreakdown;
|
||||
}
|
||||
|
||||
export function resolvePlayerOutgoingDamageResult<
|
||||
TState extends RuntimeGameStateLike<TItem>,
|
||||
TItem extends RuntimeInventoryItemLike,
|
||||
>(
|
||||
state: TState,
|
||||
character: RuntimeCharacterLike,
|
||||
baseDamage: number,
|
||||
functionMultiplier = 1,
|
||||
critRollSeed?: string,
|
||||
) {
|
||||
const buildBreakdown = getPlayerBuildDamageBreakdown(state, character);
|
||||
const equipmentBonuses = getEquipmentBonuses(state.playerEquipment);
|
||||
const attackPowerMultiplier = roundNumber(
|
||||
1 +
|
||||
(character.attributes.strength * 0.01 +
|
||||
character.attributes.agility * 0.006 +
|
||||
character.attributes.spirit * 0.004),
|
||||
4,
|
||||
);
|
||||
const critChance = roundNumber(
|
||||
clamp(0.08 + character.attributes.agility * 0.01, 0.08, 0.45),
|
||||
4,
|
||||
);
|
||||
const critDamageMultiplier = roundNumber(
|
||||
1.45 + character.attributes.strength * 0.01,
|
||||
4,
|
||||
);
|
||||
const roll = critRollSeed ? (hashSeed(critRollSeed) % 1000) / 1000 : 1;
|
||||
const isCritical = roll < critChance;
|
||||
|
||||
const damage = Math.max(
|
||||
1,
|
||||
Math.round(
|
||||
baseDamage *
|
||||
functionMultiplier *
|
||||
equipmentBonuses.outgoingDamageMultiplier *
|
||||
buildBreakdown.buildDamageMultiplier *
|
||||
attackPowerMultiplier *
|
||||
(isCritical ? critDamageMultiplier : 1),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
damage,
|
||||
isCritical,
|
||||
critChance,
|
||||
critDamageMultiplier,
|
||||
attackPowerMultiplier,
|
||||
} satisfies OutgoingDamageResult;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
formatCurrency,
|
||||
getCurrencyName,
|
||||
getInventoryItemValue,
|
||||
getNpcBuybackPrice,
|
||||
getNpcPurchasePrice,
|
||||
} from './runtimeEconomyPrimitives.js';
|
||||
import { buildTreasureResultText } from './runtimeTreasureTexts.js';
|
||||
|
||||
test('runtime economy primitives calculate trade prices on the server without src/data/economy', () => {
|
||||
const item = {
|
||||
category: '专属物品',
|
||||
name: '青铜令牌',
|
||||
rarity: 'epic' as const,
|
||||
tags: ['relic'],
|
||||
};
|
||||
|
||||
assert.equal(getCurrencyName('WUXIA'), '铜钱');
|
||||
assert.equal(getCurrencyName('XIANXIA'), '灵石');
|
||||
assert.equal(formatCurrency(48, 'WUXIA'), '48 铜钱');
|
||||
assert.equal(getInventoryItemValue(item), 118);
|
||||
assert.equal(getNpcPurchasePrice(item, 0), 118);
|
||||
assert.equal(getNpcPurchasePrice(item, 65), 99);
|
||||
assert.equal(getNpcBuybackPrice(item, 95), 68);
|
||||
});
|
||||
|
||||
test('runtime treasure text uses server-side currency formatting and reward summaries', () => {
|
||||
const text = buildTreasureResultText(
|
||||
{
|
||||
npcName: '古旧木匣',
|
||||
},
|
||||
'inspect',
|
||||
{
|
||||
items: [{ name: '残卷' }, { name: '灵药' }],
|
||||
hp: 10,
|
||||
mana: 12,
|
||||
currency: 34,
|
||||
storyHint: '你察觉这批东西与当前线索彼此呼应。',
|
||||
},
|
||||
'XIANXIA',
|
||||
);
|
||||
|
||||
assert.match(text, /34 灵石/);
|
||||
assert.match(text, /残卷、灵药/);
|
||||
assert.match(text, /气血 \+10/);
|
||||
assert.match(text, /灵力 \+12/);
|
||||
});
|
||||
75
server-node/src/modules/runtime/runtimeEconomyPrimitives.ts
Normal file
75
server-node/src/modules/runtime/runtimeEconomyPrimitives.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
type RuntimeInventoryItemLike = {
|
||||
category: string;
|
||||
name: string;
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
||||
tags: string[];
|
||||
value?: number;
|
||||
};
|
||||
|
||||
const RARITY_BASE_VALUES: Record<RuntimeInventoryItemLike['rarity'], number> = {
|
||||
common: 12,
|
||||
uncommon: 24,
|
||||
rare: 48,
|
||||
epic: 92,
|
||||
legendary: 168,
|
||||
};
|
||||
|
||||
export function getCurrencyName(worldType: string | null | undefined) {
|
||||
if (worldType === 'XIANXIA') {
|
||||
return '灵石';
|
||||
}
|
||||
if (worldType === 'WUXIA') {
|
||||
return '铜钱';
|
||||
}
|
||||
return '钱币';
|
||||
}
|
||||
|
||||
export function formatCurrency(
|
||||
value: number,
|
||||
worldType: string | null | undefined,
|
||||
) {
|
||||
return `${value} ${getCurrencyName(worldType)}`;
|
||||
}
|
||||
|
||||
export function getDiscountTierForAffinity(affinity: number) {
|
||||
if (affinity >= 90) return 3;
|
||||
if (affinity >= 60) return 2;
|
||||
if (affinity >= 30) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getInventoryItemValue(item: RuntimeInventoryItemLike) {
|
||||
if (typeof item.value === 'number' && Number.isFinite(item.value)) {
|
||||
return Math.max(8, Math.round(item.value));
|
||||
}
|
||||
|
||||
let value = RARITY_BASE_VALUES[item.rarity];
|
||||
|
||||
if (item.tags.includes('weapon')) value += 14;
|
||||
if (item.tags.includes('armor')) value += 12;
|
||||
if (item.tags.includes('relic')) value += 16;
|
||||
if (item.tags.includes('mana')) value += 8;
|
||||
if (item.tags.includes('healing')) value += 8;
|
||||
if (item.tags.includes('material')) value += 4;
|
||||
if (item.category.includes('专属')) value += 10;
|
||||
|
||||
return Math.max(8, value);
|
||||
}
|
||||
|
||||
export function getNpcPurchasePrice(
|
||||
item: RuntimeInventoryItemLike,
|
||||
affinity: number,
|
||||
) {
|
||||
const discountTier = getDiscountTierForAffinity(affinity);
|
||||
const discountMultiplier = 1 - discountTier * 0.08;
|
||||
return Math.max(6, Math.round(getInventoryItemValue(item) * discountMultiplier));
|
||||
}
|
||||
|
||||
export function getNpcBuybackPrice(
|
||||
item: RuntimeInventoryItemLike,
|
||||
affinity: number,
|
||||
) {
|
||||
const discountTier = getDiscountTierForAffinity(affinity);
|
||||
const buybackMultiplier = 0.4 + discountTier * 0.06;
|
||||
return Math.max(4, Math.round(getInventoryItemValue(item) * buybackMultiplier));
|
||||
}
|
||||
211
server-node/src/modules/runtime/runtimeEquipmentModule.ts
Normal file
211
server-node/src/modules/runtime/runtimeEquipmentModule.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
type ItemRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
||||
|
||||
type RuntimeInventoryItemLike = {
|
||||
id: string;
|
||||
category: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
rarity: ItemRarity;
|
||||
tags: string[];
|
||||
equipmentSlotId?: RuntimeEquipmentSlotId;
|
||||
statProfile?: {
|
||||
maxHpBonus?: number;
|
||||
maxManaBonus?: number;
|
||||
outgoingDamageBonus?: number;
|
||||
incomingDamageMultiplier?: number;
|
||||
};
|
||||
buildProfile?: {
|
||||
role: string;
|
||||
tags: string[];
|
||||
synergy: string[];
|
||||
forgeRank: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type RuntimeEquipmentSlotId = 'weapon' | 'armor' | 'relic';
|
||||
|
||||
export type RuntimeEquipmentLoadout<TItem = RuntimeInventoryItemLike> = {
|
||||
weapon: TItem | null;
|
||||
armor: TItem | null;
|
||||
relic: TItem | null;
|
||||
};
|
||||
|
||||
export type EquipmentBonuses = {
|
||||
maxHpBonus: number;
|
||||
maxManaBonus: number;
|
||||
outgoingDamageMultiplier: number;
|
||||
incomingDamageMultiplier: number;
|
||||
};
|
||||
|
||||
const EQUIPMENT_SLOTS: RuntimeEquipmentSlotId[] = ['weapon', 'armor', 'relic'];
|
||||
|
||||
const WEAPON_DAMAGE_BONUS: Record<ItemRarity, number> = {
|
||||
common: 0.06,
|
||||
uncommon: 0.1,
|
||||
rare: 0.14,
|
||||
epic: 0.2,
|
||||
legendary: 0.28,
|
||||
};
|
||||
|
||||
const ARMOR_HP_BONUS: Record<ItemRarity, number> = {
|
||||
common: 14,
|
||||
uncommon: 22,
|
||||
rare: 32,
|
||||
epic: 44,
|
||||
legendary: 58,
|
||||
};
|
||||
|
||||
const ARMOR_DAMAGE_MULTIPLIER: Record<ItemRarity, number> = {
|
||||
common: 0.97,
|
||||
uncommon: 0.94,
|
||||
rare: 0.9,
|
||||
epic: 0.86,
|
||||
legendary: 0.8,
|
||||
};
|
||||
|
||||
const RELIC_MANA_BONUS: Record<ItemRarity, number> = {
|
||||
common: 10,
|
||||
uncommon: 18,
|
||||
rare: 28,
|
||||
epic: 40,
|
||||
legendary: 54,
|
||||
};
|
||||
|
||||
const RELIC_DAMAGE_BONUS: Record<ItemRarity, number> = {
|
||||
common: 0.02,
|
||||
uncommon: 0.04,
|
||||
rare: 0.06,
|
||||
epic: 0.09,
|
||||
legendary: 0.12,
|
||||
};
|
||||
|
||||
export function createEmptyEquipmentLoadout<TItem = RuntimeInventoryItemLike>(): RuntimeEquipmentLoadout<TItem> {
|
||||
return {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEquipmentSlotLabel(slot: RuntimeEquipmentSlotId) {
|
||||
return {
|
||||
weapon: '武器',
|
||||
armor: '护甲',
|
||||
relic: '饰品',
|
||||
}[slot];
|
||||
}
|
||||
|
||||
function inferSlotFromText(value: string) {
|
||||
if (/武器|剑|弓|刀|拳套|战刃|佩刀|枪|刃/u.test(value)) return 'weapon' as const;
|
||||
if (/护甲|甲|护臂|衣|袍|铠/u.test(value)) return 'armor' as const;
|
||||
if (/饰品|护符|徽章|玉|珠|坠|铃|盘|令|匣/u.test(value)) return 'relic' as const;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getEquipmentSlotFromItem(
|
||||
item: RuntimeInventoryItemLike,
|
||||
): RuntimeEquipmentSlotId | null {
|
||||
if (item.equipmentSlotId) return item.equipmentSlotId;
|
||||
if (item.tags.includes('weapon')) return 'weapon';
|
||||
if (item.tags.includes('armor')) return 'armor';
|
||||
if (item.tags.includes('relic')) return 'relic';
|
||||
|
||||
return inferSlotFromText(`${item.category} ${item.name}`);
|
||||
}
|
||||
|
||||
function getFallbackBonusesForItem(slot: RuntimeEquipmentSlotId, rarity: ItemRarity) {
|
||||
if (slot === 'weapon') {
|
||||
return {
|
||||
maxHpBonus: 0,
|
||||
maxManaBonus: 0,
|
||||
outgoingDamageBonus: WEAPON_DAMAGE_BONUS[rarity],
|
||||
incomingDamageMultiplier: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (slot === 'armor') {
|
||||
return {
|
||||
maxHpBonus: ARMOR_HP_BONUS[rarity],
|
||||
maxManaBonus: 0,
|
||||
outgoingDamageBonus: 0,
|
||||
incomingDamageMultiplier: ARMOR_DAMAGE_MULTIPLIER[rarity],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
maxHpBonus: 0,
|
||||
maxManaBonus: RELIC_MANA_BONUS[rarity],
|
||||
outgoingDamageBonus: RELIC_DAMAGE_BONUS[rarity],
|
||||
incomingDamageMultiplier: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function getItemEquipmentBonuses(
|
||||
item: RuntimeInventoryItemLike,
|
||||
slot: RuntimeEquipmentSlotId,
|
||||
) {
|
||||
const fallback = getFallbackBonusesForItem(slot, item.rarity);
|
||||
|
||||
return {
|
||||
maxHpBonus: item.statProfile?.maxHpBonus ?? fallback.maxHpBonus,
|
||||
maxManaBonus: item.statProfile?.maxManaBonus ?? fallback.maxManaBonus,
|
||||
outgoingDamageBonus:
|
||||
item.statProfile?.outgoingDamageBonus ?? fallback.outgoingDamageBonus,
|
||||
incomingDamageMultiplier:
|
||||
item.statProfile?.incomingDamageMultiplier ?? fallback.incomingDamageMultiplier,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEquipmentBonuses<TItem extends RuntimeInventoryItemLike>(
|
||||
loadout: RuntimeEquipmentLoadout<TItem>,
|
||||
): EquipmentBonuses {
|
||||
let maxHpBonus = 0;
|
||||
let maxManaBonus = 0;
|
||||
let outgoingDamageBonus = 0;
|
||||
let incomingDamageMultiplier = 1;
|
||||
|
||||
EQUIPMENT_SLOTS.forEach((slot) => {
|
||||
const item = loadout[slot];
|
||||
if (!item) return;
|
||||
|
||||
const itemBonuses = getItemEquipmentBonuses(item, slot);
|
||||
maxHpBonus += itemBonuses.maxHpBonus;
|
||||
maxManaBonus += itemBonuses.maxManaBonus;
|
||||
outgoingDamageBonus += itemBonuses.outgoingDamageBonus;
|
||||
incomingDamageMultiplier *= itemBonuses.incomingDamageMultiplier;
|
||||
});
|
||||
|
||||
return {
|
||||
maxHpBonus,
|
||||
maxManaBonus,
|
||||
outgoingDamageMultiplier: Number((1 + outgoingDamageBonus).toFixed(4)),
|
||||
incomingDamageMultiplier: Number(incomingDamageMultiplier.toFixed(4)),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyEquipmentLoadoutToState<
|
||||
TState extends {
|
||||
playerMaxHp: number;
|
||||
playerHp: number;
|
||||
playerMaxMana: number;
|
||||
playerMana: number;
|
||||
playerEquipment: RuntimeEquipmentLoadout<TItem>;
|
||||
},
|
||||
TItem extends RuntimeInventoryItemLike,
|
||||
>(state: TState, nextEquipment: RuntimeEquipmentLoadout<TItem>) {
|
||||
const previousBonuses = getEquipmentBonuses(state.playerEquipment);
|
||||
const nextBonuses = getEquipmentBonuses(nextEquipment);
|
||||
const baseMaxHp = Math.max(1, state.playerMaxHp - previousBonuses.maxHpBonus);
|
||||
const baseMaxMana = Math.max(1, state.playerMaxMana - previousBonuses.maxManaBonus);
|
||||
const nextMaxHp = baseMaxHp + nextBonuses.maxHpBonus;
|
||||
const nextMaxMana = baseMaxMana + nextBonuses.maxManaBonus;
|
||||
|
||||
return {
|
||||
...state,
|
||||
playerMaxHp: nextMaxHp,
|
||||
playerHp: Math.min(nextMaxHp, state.playerHp),
|
||||
playerMaxMana: nextMaxMana,
|
||||
playerMana: nextMaxMana,
|
||||
playerEquipment: nextEquipment,
|
||||
};
|
||||
}
|
||||
468
server-node/src/modules/runtime/runtimeForgeModule.ts
Normal file
468
server-node/src/modules/runtime/runtimeForgeModule.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
import { formatCurrency } from './runtimeEconomyPrimitives.js';
|
||||
import {
|
||||
addInventoryItems,
|
||||
removeInventoryItem,
|
||||
} from './runtimeStatePrimitives.js';
|
||||
import {
|
||||
getEquipmentSlotFromItem,
|
||||
getEquipmentSlotLabel,
|
||||
type RuntimeEquipmentSlotId,
|
||||
} from './runtimeEquipmentModule.js';
|
||||
|
||||
type RuntimeInventoryItemLike = {
|
||||
id: string;
|
||||
category: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
||||
tags: string[];
|
||||
equipmentSlotId?: RuntimeEquipmentSlotId;
|
||||
statProfile?: {
|
||||
maxHpBonus?: number;
|
||||
maxManaBonus?: number;
|
||||
outgoingDamageBonus?: number;
|
||||
incomingDamageMultiplier?: number;
|
||||
};
|
||||
buildProfile?: {
|
||||
role: string;
|
||||
tags: string[];
|
||||
synergy: string[];
|
||||
forgeRank: number;
|
||||
};
|
||||
};
|
||||
|
||||
type ForgeRequirement<TItem> = {
|
||||
id: string;
|
||||
label: string;
|
||||
quantity: number;
|
||||
matches: (item: TItem) => boolean;
|
||||
};
|
||||
|
||||
type ForgeRecipeDefinition<TItem> = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'synthesis' | 'forge';
|
||||
description: string;
|
||||
resultLabel: string;
|
||||
currencyCost: number;
|
||||
requirements: ForgeRequirement<TItem>[];
|
||||
createResult: (worldType: string | null | undefined) => TItem;
|
||||
};
|
||||
|
||||
function createItemId(prefix: string) {
|
||||
return `${prefix}:${Date.now().toString(36)}:${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function normalizeBuildTags(tags: string[]) {
|
||||
return [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function buildMaterialItem(
|
||||
name: string,
|
||||
quantity: number,
|
||||
tags: string[],
|
||||
rarity: RuntimeInventoryItemLike['rarity'] = 'uncommon',
|
||||
description?: string,
|
||||
) {
|
||||
return {
|
||||
id: createItemId(`forge-material:${name}`),
|
||||
category: '材料',
|
||||
name,
|
||||
quantity: Math.max(1, Math.floor(quantity)),
|
||||
rarity,
|
||||
tags: ['material', ...normalizeBuildTags(tags)],
|
||||
description,
|
||||
buildProfile: {
|
||||
role: '工巧',
|
||||
tags: normalizeBuildTags(tags),
|
||||
synergy: normalizeBuildTags(tags),
|
||||
forgeRank: 0,
|
||||
},
|
||||
} satisfies RuntimeInventoryItemLike;
|
||||
}
|
||||
|
||||
function buildEquipmentItem(params: {
|
||||
name: string;
|
||||
slot: RuntimeEquipmentSlotId;
|
||||
rarity: RuntimeInventoryItemLike['rarity'];
|
||||
description: string;
|
||||
role: string;
|
||||
tags: string[];
|
||||
synergy: string[];
|
||||
statProfile: NonNullable<RuntimeInventoryItemLike['statProfile']>;
|
||||
}) {
|
||||
return {
|
||||
id: createItemId(`forge-equip:${params.name}`),
|
||||
category: getEquipmentSlotLabel(params.slot),
|
||||
name: params.name,
|
||||
quantity: 1,
|
||||
rarity: params.rarity,
|
||||
tags: [
|
||||
params.slot === 'weapon' ? 'weapon' : params.slot === 'armor' ? 'armor' : 'relic',
|
||||
...normalizeBuildTags(params.tags),
|
||||
],
|
||||
description: params.description,
|
||||
equipmentSlotId: params.slot,
|
||||
statProfile: params.statProfile,
|
||||
buildProfile: {
|
||||
role: params.role,
|
||||
tags: normalizeBuildTags(params.tags),
|
||||
synergy: normalizeBuildTags(params.synergy),
|
||||
forgeRank: 1,
|
||||
},
|
||||
} satisfies RuntimeInventoryItemLike;
|
||||
}
|
||||
|
||||
function buildNamedMaterialRequirement<TItem extends RuntimeInventoryItemLike>(
|
||||
name: string,
|
||||
quantity: number,
|
||||
): ForgeRequirement<TItem> {
|
||||
return {
|
||||
id: `name:${name}`,
|
||||
label: name,
|
||||
quantity,
|
||||
matches: (item) => item.name === name,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAnyMaterialRequirement<TItem extends RuntimeInventoryItemLike>(
|
||||
id: string,
|
||||
label: string,
|
||||
quantity: number,
|
||||
): ForgeRequirement<TItem> {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
quantity,
|
||||
matches: (item) => item.tags.includes('material') || item.category.includes('材料'),
|
||||
};
|
||||
}
|
||||
|
||||
function buildForgeRecipes<TItem extends RuntimeInventoryItemLike>() {
|
||||
return [
|
||||
{
|
||||
id: 'synthesis-refined-ingot',
|
||||
name: '压炼锭材',
|
||||
kind: 'synthesis',
|
||||
description: '把零散残片和基础材料压成稳定可用的金属锭材。',
|
||||
resultLabel: '精炼锭材',
|
||||
currencyCost: 18,
|
||||
requirements: [buildAnyMaterialRequirement<TItem>('material:any', '任意材料', 3)],
|
||||
createResult: () =>
|
||||
buildMaterialItem('精炼锭材', 1, ['工巧', '守御'], 'rare') as TItem,
|
||||
},
|
||||
{
|
||||
id: 'forge-duelist-blade',
|
||||
name: '锻造 百炼追风剑',
|
||||
kind: 'forge',
|
||||
description: '围绕快剑、突进、追击构筑的轻灵主武器。',
|
||||
resultLabel: '百炼追风剑',
|
||||
currencyCost: 72,
|
||||
requirements: [
|
||||
buildNamedMaterialRequirement<TItem>('精炼锭材', 2),
|
||||
buildNamedMaterialRequirement<TItem>('快剑精粹', 1),
|
||||
],
|
||||
createResult: () =>
|
||||
buildEquipmentItem({
|
||||
name: '百炼追风剑',
|
||||
slot: 'weapon',
|
||||
rarity: 'epic',
|
||||
description: '为快剑与追身构筑准备的锻造兵刃。',
|
||||
role: '快剑',
|
||||
tags: ['快剑', '突进', '追击'],
|
||||
synergy: ['快剑', '突进', '追击'],
|
||||
statProfile: {
|
||||
maxManaBonus: 10,
|
||||
outgoingDamageBonus: 0.2,
|
||||
},
|
||||
}) as TItem,
|
||||
},
|
||||
] satisfies ForgeRecipeDefinition<TItem>[];
|
||||
}
|
||||
|
||||
type ForgeRecipeView = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'synthesis' | 'forge';
|
||||
description: string;
|
||||
resultLabel: string;
|
||||
currencyCost: number;
|
||||
currencyText: string;
|
||||
requirements: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
quantity: number;
|
||||
owned: number;
|
||||
}>;
|
||||
canCraft: boolean;
|
||||
};
|
||||
|
||||
function countMatchingItems<TItem extends RuntimeInventoryItemLike>(
|
||||
inventory: TItem[],
|
||||
requirement: ForgeRequirement<TItem>,
|
||||
) {
|
||||
return inventory
|
||||
.filter((item) => requirement.matches(item))
|
||||
.reduce((sum, item) => sum + item.quantity, 0);
|
||||
}
|
||||
|
||||
function consumeRequirement<TItem extends RuntimeInventoryItemLike>(
|
||||
inventory: TItem[],
|
||||
requirement: ForgeRequirement<TItem>,
|
||||
) {
|
||||
let remaining = requirement.quantity;
|
||||
let nextInventory = [...inventory];
|
||||
|
||||
for (const item of inventory) {
|
||||
if (remaining <= 0) break;
|
||||
if (!requirement.matches(item)) continue;
|
||||
|
||||
const consumed = Math.min(item.quantity, remaining);
|
||||
nextInventory = removeInventoryItem(nextInventory, item.id, consumed);
|
||||
remaining -= consumed;
|
||||
}
|
||||
|
||||
return remaining === 0 ? nextInventory : null;
|
||||
}
|
||||
|
||||
function applyRequirementsIfPossible<TItem extends RuntimeInventoryItemLike>(
|
||||
inventory: TItem[],
|
||||
requirements: ForgeRequirement<TItem>[],
|
||||
) {
|
||||
let nextInventory = [...inventory];
|
||||
for (const requirement of requirements) {
|
||||
const consumedInventory = consumeRequirement(nextInventory, requirement);
|
||||
if (!consumedInventory) return null;
|
||||
nextInventory = consumedInventory;
|
||||
}
|
||||
return nextInventory;
|
||||
}
|
||||
|
||||
function buildTagEssence(tag: string) {
|
||||
return buildMaterialItem(`${tag}精粹`, 1, [tag, '工巧'], 'rare');
|
||||
}
|
||||
|
||||
function buildDismantleBaseMaterials(
|
||||
item: RuntimeInventoryItemLike,
|
||||
slot: RuntimeEquipmentSlotId | null,
|
||||
) {
|
||||
const rarityScale: Record<RuntimeInventoryItemLike['rarity'], number> = {
|
||||
common: 1,
|
||||
uncommon: 2,
|
||||
rare: 3,
|
||||
epic: 4,
|
||||
legendary: 5,
|
||||
};
|
||||
|
||||
const amount = rarityScale[item.rarity];
|
||||
if (slot === 'weapon') {
|
||||
return [buildMaterialItem('武器残片', amount, ['工巧', '重击'])];
|
||||
}
|
||||
if (slot === 'armor') {
|
||||
return [buildMaterialItem('甲片', amount, ['工巧', '守御'])];
|
||||
}
|
||||
if (slot === 'relic') {
|
||||
return [buildMaterialItem('灵饰碎片', amount, ['工巧', '法力'])];
|
||||
}
|
||||
|
||||
return [buildMaterialItem('零散材料', Math.max(1, Math.ceil(amount / 2)), ['工巧'])];
|
||||
}
|
||||
|
||||
function buildDismantleEssences(item: RuntimeInventoryItemLike) {
|
||||
const buildTags = normalizeBuildTags([
|
||||
...(item.buildProfile?.tags ?? []),
|
||||
item.buildProfile?.role ?? '',
|
||||
]).slice(0, item.rarity === 'legendary' ? 3 : 2);
|
||||
|
||||
return buildTags.map((tag) => buildTagEssence(tag));
|
||||
}
|
||||
|
||||
function getReforgeCost<TItem extends RuntimeInventoryItemLike>(
|
||||
slot: RuntimeEquipmentSlotId | null,
|
||||
) {
|
||||
if (slot === 'relic') {
|
||||
return {
|
||||
requirements: [buildNamedMaterialRequirement<TItem>('凝光纱', 1)],
|
||||
currencyCost: 52,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
requirements: [buildNamedMaterialRequirement<TItem>('精炼锭材', 1)],
|
||||
currencyCost: 46,
|
||||
};
|
||||
}
|
||||
|
||||
function buildReforgedItem(item: RuntimeInventoryItemLike) {
|
||||
const slot = getEquipmentSlotFromItem(item);
|
||||
if (!slot || !item.buildProfile) return null;
|
||||
|
||||
const nextTags = normalizeBuildTags([
|
||||
...item.buildProfile.tags,
|
||||
slot === 'weapon' ? '追击' : slot === 'armor' ? '护体' : '法力',
|
||||
]).slice(0, 3);
|
||||
|
||||
return {
|
||||
...item,
|
||||
id: createItemId(`reforge:${item.name}`),
|
||||
name: item.name.includes('重铸') ? item.name : `${item.name}·重铸`,
|
||||
statProfile: {
|
||||
...item.statProfile,
|
||||
maxHpBonus: (item.statProfile?.maxHpBonus ?? 0) + (slot === 'armor' ? 10 : 4),
|
||||
maxManaBonus: (item.statProfile?.maxManaBonus ?? 0) + (slot === 'relic' ? 10 : 4),
|
||||
outgoingDamageBonus: Number(
|
||||
(((item.statProfile?.outgoingDamageBonus ?? 0) + 0.03)).toFixed(3),
|
||||
),
|
||||
incomingDamageMultiplier:
|
||||
typeof item.statProfile?.incomingDamageMultiplier === 'number'
|
||||
? Number(Math.max(0.72, item.statProfile.incomingDamageMultiplier - 0.03).toFixed(3))
|
||||
: slot === 'armor'
|
||||
? 0.94
|
||||
: 0.97,
|
||||
},
|
||||
buildProfile: {
|
||||
...item.buildProfile,
|
||||
tags: nextTags,
|
||||
synergy: nextTags,
|
||||
forgeRank: (item.buildProfile.forgeRank ?? 0) + 1,
|
||||
},
|
||||
} satisfies RuntimeInventoryItemLike;
|
||||
}
|
||||
|
||||
export function getForgeRecipeViews<TItem extends RuntimeInventoryItemLike>(
|
||||
inventory: TItem[],
|
||||
playerCurrency = 0,
|
||||
worldType: string | null | undefined = null,
|
||||
) {
|
||||
return buildForgeRecipes<TItem>().map((recipe) => ({
|
||||
id: recipe.id,
|
||||
name: recipe.name,
|
||||
kind: recipe.kind,
|
||||
description: recipe.description,
|
||||
resultLabel: recipe.resultLabel,
|
||||
currencyCost: recipe.currencyCost,
|
||||
currencyText: formatCurrency(recipe.currencyCost, worldType),
|
||||
requirements: recipe.requirements.map((requirement) => ({
|
||||
id: requirement.id,
|
||||
label: requirement.label,
|
||||
quantity: requirement.quantity,
|
||||
owned: countMatchingItems(inventory, requirement),
|
||||
})),
|
||||
canCraft:
|
||||
playerCurrency >= recipe.currencyCost &&
|
||||
recipe.requirements.every(
|
||||
(requirement) => countMatchingItems(inventory, requirement) >= requirement.quantity,
|
||||
),
|
||||
})) satisfies ForgeRecipeView[];
|
||||
}
|
||||
|
||||
export function executeForgeRecipe<TItem extends RuntimeInventoryItemLike>(
|
||||
inventory: TItem[],
|
||||
recipeId: string,
|
||||
worldType: string | null | undefined,
|
||||
playerCurrency: number,
|
||||
) {
|
||||
const recipe = buildForgeRecipes<TItem>().find((candidate) => candidate.id === recipeId);
|
||||
if (!recipe || playerCurrency < recipe.currencyCost) return null;
|
||||
|
||||
const consumedInventory = applyRequirementsIfPossible(inventory, recipe.requirements);
|
||||
if (!consumedInventory) return null;
|
||||
|
||||
const createdItem = recipe.createResult(worldType);
|
||||
return {
|
||||
inventory: addInventoryItems(consumedInventory, [createdItem]),
|
||||
currency: playerCurrency - recipe.currencyCost,
|
||||
createdItem,
|
||||
};
|
||||
}
|
||||
|
||||
export function executeDismantleItem<TItem extends RuntimeInventoryItemLike>(
|
||||
inventory: TItem[],
|
||||
itemId: string,
|
||||
) {
|
||||
const targetItem = inventory.find((item) => item.id === itemId);
|
||||
if (!targetItem || targetItem.quantity <= 0) return null;
|
||||
|
||||
const slot = getEquipmentSlotFromItem(targetItem);
|
||||
if (!slot && !targetItem.buildProfile) return null;
|
||||
|
||||
const outputs = [
|
||||
...buildDismantleBaseMaterials(targetItem, slot),
|
||||
...buildDismantleEssences(targetItem),
|
||||
] as TItem[];
|
||||
|
||||
return {
|
||||
inventory: addInventoryItems(removeInventoryItem(inventory, itemId, 1), outputs),
|
||||
outputs,
|
||||
};
|
||||
}
|
||||
|
||||
export function executeReforgeItem<TItem extends RuntimeInventoryItemLike>(
|
||||
inventory: TItem[],
|
||||
itemId: string,
|
||||
playerCurrency: number,
|
||||
) {
|
||||
const targetItem = inventory.find((item) => item.id === itemId);
|
||||
if (!targetItem || targetItem.quantity <= 0) return null;
|
||||
|
||||
const slot = getEquipmentSlotFromItem(targetItem);
|
||||
const reforgedItem = buildReforgedItem(targetItem) as TItem | null;
|
||||
const reforgeCost = getReforgeCost<TItem>(slot);
|
||||
if (!reforgedItem || playerCurrency < reforgeCost.currencyCost) return null;
|
||||
|
||||
const consumedInventory = applyRequirementsIfPossible(
|
||||
removeInventoryItem(inventory, itemId, 1),
|
||||
reforgeCost.requirements,
|
||||
);
|
||||
if (!consumedInventory) return null;
|
||||
|
||||
return {
|
||||
inventory: addInventoryItems(consumedInventory, [reforgedItem]),
|
||||
reforgedItem,
|
||||
currencyCost: reforgeCost.currencyCost,
|
||||
};
|
||||
}
|
||||
|
||||
export function getReforgeCostView<TItem extends RuntimeInventoryItemLike>(
|
||||
item: TItem,
|
||||
worldType: string | null | undefined,
|
||||
) {
|
||||
const slot = getEquipmentSlotFromItem(item);
|
||||
const cost = getReforgeCost<TItem>(slot);
|
||||
return {
|
||||
currencyCost: cost.currencyCost,
|
||||
currencyText: formatCurrency(cost.currencyCost, worldType),
|
||||
requirements: cost.requirements.map((requirement) => ({
|
||||
id: requirement.id,
|
||||
label: requirement.label,
|
||||
quantity: requirement.quantity,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildForgeSuccessText(
|
||||
action: 'craft' | 'dismantle' | 'reforge',
|
||||
params: {
|
||||
sourceItemName?: string;
|
||||
recipeName?: string;
|
||||
createdItemName?: string;
|
||||
outputNames?: string[];
|
||||
currencyText?: string;
|
||||
},
|
||||
) {
|
||||
if (action === 'craft') {
|
||||
return `你在工坊中完成了${params.recipeName},获得了${params.createdItemName}${
|
||||
params.currencyText ? `,并支付了${params.currencyText}` : ''
|
||||
}。`;
|
||||
}
|
||||
|
||||
if (action === 'reforge') {
|
||||
return `你消耗材料重新淬炼了${params.sourceItemName},最终得到${params.createdItemName}${
|
||||
params.currencyText ? `,并支付了${params.currencyText}` : ''
|
||||
}。`;
|
||||
}
|
||||
|
||||
return `你拆解了${params.sourceItemName},回收出${(params.outputNames ?? []).join('、')}。`;
|
||||
}
|
||||
130
server-node/src/modules/runtime/runtimeInventoryEffectsModule.ts
Normal file
130
server-node/src/modules/runtime/runtimeInventoryEffectsModule.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
type RuntimeCharacterLike = {
|
||||
attributes: {
|
||||
strength: number;
|
||||
agility: number;
|
||||
intelligence: number;
|
||||
spirit: number;
|
||||
};
|
||||
};
|
||||
|
||||
type RuntimeBuildBuff = {
|
||||
id: string;
|
||||
sourceType: 'item';
|
||||
sourceId: string;
|
||||
name: string;
|
||||
tags: string[];
|
||||
durationTurns: number;
|
||||
};
|
||||
|
||||
type RuntimeInventoryItemLike = {
|
||||
name: string;
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
||||
tags: string[];
|
||||
useProfile?: {
|
||||
hpRestore?: number;
|
||||
manaRestore?: number;
|
||||
cooldownReduction?: number;
|
||||
buildBuffs?: RuntimeBuildBuff[];
|
||||
};
|
||||
};
|
||||
|
||||
export type InventoryUseEffect = {
|
||||
hpRestore: number;
|
||||
manaRestore: number;
|
||||
cooldownReduction: number;
|
||||
buildBuffs: RuntimeBuildBuff[];
|
||||
};
|
||||
|
||||
function getRarityMultiplier(rarity: RuntimeInventoryItemLike['rarity']) {
|
||||
switch (rarity) {
|
||||
case 'legendary':
|
||||
return 2.4;
|
||||
case 'epic':
|
||||
return 1.9;
|
||||
case 'rare':
|
||||
return 1.55;
|
||||
case 'uncommon':
|
||||
return 1.2;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function isInventoryItemUsable(item: RuntimeInventoryItemLike) {
|
||||
return (
|
||||
Boolean(item.useProfile) ||
|
||||
item.tags.includes('healing') ||
|
||||
item.tags.includes('mana')
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveInventoryItemUseEffect(
|
||||
item: RuntimeInventoryItemLike,
|
||||
character: RuntimeCharacterLike,
|
||||
): InventoryUseEffect | null {
|
||||
if (!isInventoryItemUsable(item)) return null;
|
||||
|
||||
if (item.useProfile) {
|
||||
return {
|
||||
hpRestore: item.useProfile.hpRestore ?? 0,
|
||||
manaRestore: item.useProfile.manaRestore ?? 0,
|
||||
cooldownReduction: item.useProfile.cooldownReduction ?? 0,
|
||||
buildBuffs: item.useProfile.buildBuffs ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
const rarityMultiplier = getRarityMultiplier(item.rarity);
|
||||
const hasHealing =
|
||||
item.tags.includes('healing') ||
|
||||
/药|包|补给|恢复|疗伤|meat|apple|mushroom|water/i.test(item.name);
|
||||
const hasMana =
|
||||
item.tags.includes('mana') ||
|
||||
/灵液|法力|mana|crystal|essence|spirit/i.test(item.name);
|
||||
|
||||
const hpRestore = hasHealing
|
||||
? Math.max(
|
||||
10,
|
||||
Math.round((14 + character.attributes.spirit * 1.4) * rarityMultiplier),
|
||||
)
|
||||
: 0;
|
||||
const manaRestore = hasMana
|
||||
? Math.max(
|
||||
8,
|
||||
Math.round(
|
||||
(12 + character.attributes.intelligence * 1.4) * rarityMultiplier,
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
const cooldownReduction = /凝神|回气|醒神|booster|essence/i.test(item.name)
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
if (hpRestore <= 0 && manaRestore <= 0 && cooldownReduction <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
hpRestore,
|
||||
manaRestore,
|
||||
cooldownReduction,
|
||||
buildBuffs: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildInventoryUseResultText(
|
||||
item: RuntimeInventoryItemLike,
|
||||
effect: InventoryUseEffect,
|
||||
) {
|
||||
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 `你取出${item.name}立刻使用,${parts.join(',')}。`;
|
||||
}
|
||||
88
server-node/src/modules/runtime/runtimeNarrativeMemory.ts
Normal file
88
server-node/src/modules/runtime/runtimeNarrativeMemory.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
function dedupeStrings(values: Array<string | null | undefined>, limit = 16) {
|
||||
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
|
||||
.slice(-limit);
|
||||
}
|
||||
|
||||
type RuntimeStoryFingerprint = {
|
||||
relatedScarIds?: string[];
|
||||
relatedThreadIds?: string[];
|
||||
visibleClue?: string | null;
|
||||
};
|
||||
|
||||
type RuntimeInventoryItemLike = {
|
||||
id: string;
|
||||
runtimeMetadata?: {
|
||||
storyFingerprint?: RuntimeStoryFingerprint | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type RuntimeStoryEngineMemoryLike = {
|
||||
discoveredFactIds: string[];
|
||||
inferredFactIds?: string[];
|
||||
activeThreadIds: string[];
|
||||
resolvedScarIds: string[];
|
||||
recentCarrierIds: string[];
|
||||
};
|
||||
|
||||
type RuntimeGameStateLike = {
|
||||
storyEngineMemory?: RuntimeStoryEngineMemoryLike | null;
|
||||
};
|
||||
|
||||
function createEmptyStoryEngineMemoryState(): RuntimeStoryEngineMemoryLike {
|
||||
return {
|
||||
discoveredFactIds: [],
|
||||
activeThreadIds: [],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function appendStoryEngineCarrierMemory<
|
||||
TState extends RuntimeGameStateLike,
|
||||
TItem extends RuntimeInventoryItemLike,
|
||||
>(state: TState, items: TItem[]) {
|
||||
const storyEngineMemory =
|
||||
state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
const carriers = items.filter((item) => item.runtimeMetadata?.storyFingerprint);
|
||||
if (carriers.length <= 0) {
|
||||
return {
|
||||
...state,
|
||||
storyEngineMemory,
|
||||
};
|
||||
}
|
||||
|
||||
const recentCarrierIds = dedupeStrings(
|
||||
[...storyEngineMemory.recentCarrierIds, ...carriers.map((item) => item.id)],
|
||||
8,
|
||||
);
|
||||
const scarIds = carriers.flatMap(
|
||||
(item) => item.runtimeMetadata?.storyFingerprint?.relatedScarIds ?? [],
|
||||
);
|
||||
const threadIds = carriers.flatMap(
|
||||
(item) => item.runtimeMetadata?.storyFingerprint?.relatedThreadIds ?? [],
|
||||
);
|
||||
const visibleClues = carriers.flatMap((item) => {
|
||||
const clue = item.runtimeMetadata?.storyFingerprint?.visibleClue;
|
||||
return clue ? [clue] : [];
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
recentCarrierIds,
|
||||
resolvedScarIds: dedupeStrings(
|
||||
[...storyEngineMemory.resolvedScarIds, ...scarIds],
|
||||
10,
|
||||
),
|
||||
activeThreadIds: dedupeStrings(
|
||||
[...storyEngineMemory.activeThreadIds, ...threadIds],
|
||||
8,
|
||||
),
|
||||
discoveredFactIds: dedupeStrings(
|
||||
[...storyEngineMemory.discoveredFactIds, ...visibleClues],
|
||||
24,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
markNpcFirstMeaningfulContactResolved,
|
||||
normalizeNpcPersistentState,
|
||||
} from './runtimeNpcStatePrimitives.js';
|
||||
import { appendStoryEngineCarrierMemory } from './runtimeNarrativeMemory.js';
|
||||
|
||||
test('runtime npc state primitives normalize arrays, relation state and stance defaults on the server', () => {
|
||||
const normalized = normalizeNpcPersistentState({
|
||||
affinity: 18,
|
||||
recruited: false,
|
||||
revealedFacts: ['thread:a', 1, null],
|
||||
knownAttributeRumors: ['力量偏盛', false],
|
||||
seenBackstoryChapterIds: ['past-1', 2],
|
||||
stanceProfile: null,
|
||||
});
|
||||
|
||||
assert.equal(normalized.relationState.stance, 'neutral');
|
||||
assert.deepEqual(normalized.revealedFacts, ['thread:a']);
|
||||
assert.deepEqual(normalized.knownAttributeRumors, ['力量偏盛']);
|
||||
assert.deepEqual(normalized.seenBackstoryChapterIds, ['past-1']);
|
||||
assert.equal(normalized.firstMeaningfulContactResolved, false);
|
||||
assert.equal(normalized.stanceProfile.currentConflictTag, null);
|
||||
});
|
||||
|
||||
test('runtime npc state primitives can mark first meaningful contact as resolved locally on the server', () => {
|
||||
const nextState = markNpcFirstMeaningfulContactResolved({
|
||||
affinity: 64,
|
||||
recruited: false,
|
||||
revealedFacts: [],
|
||||
knownAttributeRumors: [],
|
||||
seenBackstoryChapterIds: [],
|
||||
firstMeaningfulContactResolved: false,
|
||||
});
|
||||
|
||||
assert.equal(nextState.firstMeaningfulContactResolved, true);
|
||||
assert.equal(nextState.relationState.stance, 'bonded');
|
||||
});
|
||||
|
||||
test('runtime narrative memory appends carrier facts without depending on src/services/storyEngine/echoMemory', () => {
|
||||
const nextState = appendStoryEngineCarrierMemory(
|
||||
{
|
||||
storyEngineMemory: {
|
||||
discoveredFactIds: ['clue:old'],
|
||||
activeThreadIds: ['thread:old'],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
},
|
||||
},
|
||||
[
|
||||
{
|
||||
id: 'carrier-1',
|
||||
runtimeMetadata: {
|
||||
storyFingerprint: {
|
||||
relatedScarIds: ['scar:one'],
|
||||
relatedThreadIds: ['thread:new'],
|
||||
visibleClue: 'clue:new',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert.deepEqual(nextState.storyEngineMemory.recentCarrierIds, ['carrier-1']);
|
||||
assert.deepEqual(nextState.storyEngineMemory.resolvedScarIds, ['scar:one']);
|
||||
assert.deepEqual(nextState.storyEngineMemory.activeThreadIds, [
|
||||
'thread:old',
|
||||
'thread:new',
|
||||
]);
|
||||
assert.deepEqual(nextState.storyEngineMemory.discoveredFactIds, [
|
||||
'clue:old',
|
||||
'clue:new',
|
||||
]);
|
||||
});
|
||||
117
server-node/src/modules/runtime/runtimeNpcStatePrimitives.ts
Normal file
117
server-node/src/modules/runtime/runtimeNpcStatePrimitives.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { buildRelationState } from './runtimeStatePrimitives.js';
|
||||
|
||||
type RuntimeNpcStanceProfile = {
|
||||
trust?: number;
|
||||
warmth?: number;
|
||||
ideologicalFit?: number;
|
||||
fearOrGuard?: number;
|
||||
loyalty?: number;
|
||||
currentConflictTag?: string | null;
|
||||
recentApprovals?: unknown;
|
||||
recentDisapprovals?: unknown;
|
||||
};
|
||||
|
||||
type RuntimeNpcPersistentStateLike = {
|
||||
affinity: number;
|
||||
recruited?: boolean;
|
||||
relationState?: unknown;
|
||||
revealedFacts?: unknown;
|
||||
knownAttributeRumors?: unknown;
|
||||
tradeStockSignature?: string | null;
|
||||
firstMeaningfulContactResolved?: boolean;
|
||||
seenBackstoryChapterIds?: unknown;
|
||||
stanceProfile?: RuntimeNpcStanceProfile | null;
|
||||
};
|
||||
|
||||
function clampStanceMetric(value: number) {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
function normalizeRecentStanceNotes(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value
|
||||
.filter(
|
||||
(item): item is string => typeof item === 'string' && item.trim().length > 0,
|
||||
)
|
||||
.slice(-3)
|
||||
: [];
|
||||
}
|
||||
|
||||
function buildInitialStanceProfile(
|
||||
affinity: number,
|
||||
options: {
|
||||
recruited?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const recruitedBonus = options.recruited ? 14 : 0;
|
||||
|
||||
return {
|
||||
trust: clampStanceMetric(42 + affinity * 0.55 + recruitedBonus),
|
||||
warmth: clampStanceMetric(36 + affinity * 0.5 + recruitedBonus),
|
||||
ideologicalFit: clampStanceMetric(48 + affinity * 0.25),
|
||||
fearOrGuard: clampStanceMetric(62 - affinity * 0.55),
|
||||
loyalty: clampStanceMetric(24 + affinity * 0.35 + (options.recruited ? 26 : 0)),
|
||||
currentConflictTag: null,
|
||||
recentApprovals: [],
|
||||
recentDisapprovals: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStanceProfile(
|
||||
stanceProfile: RuntimeNpcPersistentStateLike['stanceProfile'],
|
||||
npcState: RuntimeNpcPersistentStateLike,
|
||||
) {
|
||||
if (!stanceProfile) {
|
||||
return buildInitialStanceProfile(npcState.affinity, {
|
||||
recruited: npcState.recruited,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
trust: clampStanceMetric(stanceProfile.trust ?? 40),
|
||||
warmth: clampStanceMetric(stanceProfile.warmth ?? 35),
|
||||
ideologicalFit: clampStanceMetric(stanceProfile.ideologicalFit ?? 45),
|
||||
fearOrGuard: clampStanceMetric(stanceProfile.fearOrGuard ?? 55),
|
||||
loyalty: clampStanceMetric(stanceProfile.loyalty ?? 20),
|
||||
currentConflictTag: stanceProfile.currentConflictTag ?? null,
|
||||
recentApprovals: normalizeRecentStanceNotes(stanceProfile.recentApprovals),
|
||||
recentDisapprovals: normalizeRecentStanceNotes(stanceProfile.recentDisapprovals),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeNpcPersistentState<
|
||||
TNpcState extends RuntimeNpcPersistentStateLike,
|
||||
>(npcState: TNpcState) {
|
||||
return {
|
||||
...npcState,
|
||||
relationState: buildRelationState(npcState.affinity),
|
||||
revealedFacts: Array.isArray(npcState.revealedFacts)
|
||||
? npcState.revealedFacts.filter(
|
||||
(fact): fact is string => typeof fact === 'string',
|
||||
)
|
||||
: [],
|
||||
knownAttributeRumors: Array.isArray(npcState.knownAttributeRumors)
|
||||
? npcState.knownAttributeRumors.filter(
|
||||
(fact): fact is string => typeof fact === 'string',
|
||||
)
|
||||
: [],
|
||||
tradeStockSignature: npcState.tradeStockSignature ?? null,
|
||||
firstMeaningfulContactResolved:
|
||||
npcState.firstMeaningfulContactResolved ?? false,
|
||||
seenBackstoryChapterIds: Array.isArray(npcState.seenBackstoryChapterIds)
|
||||
? npcState.seenBackstoryChapterIds.filter(
|
||||
(fact): fact is string => typeof fact === 'string',
|
||||
)
|
||||
: [],
|
||||
stanceProfile: normalizeStanceProfile(npcState.stanceProfile, npcState),
|
||||
};
|
||||
}
|
||||
|
||||
export function markNpcFirstMeaningfulContactResolved<
|
||||
TNpcState extends RuntimeNpcPersistentStateLike,
|
||||
>(npcState: TNpcState) {
|
||||
return normalizeNpcPersistentState({
|
||||
...npcState,
|
||||
firstMeaningfulContactResolved: true,
|
||||
});
|
||||
}
|
||||
206
server-node/src/modules/runtime/runtimeSnapshotHydration.test.ts
Normal file
206
server-node/src/modules/runtime/runtimeSnapshotHydration.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { hydrateSavedSnapshot } from './runtimeSnapshotHydration.js';
|
||||
|
||||
test('runtime snapshot hydration normalizes server snapshots for frontend restore flows', () => {
|
||||
const snapshot = hydrateSavedSnapshot({
|
||||
version: 2,
|
||||
savedAt: '2026-04-09T00:00:00.000Z',
|
||||
bottomTab: 'unknown-tab',
|
||||
gameState: {
|
||||
currentScene: 'Story',
|
||||
worldType: 'WUXIA',
|
||||
playerCharacter: {
|
||||
id: 'hero',
|
||||
title: '试剑客',
|
||||
description: '在风里试探局势的人。',
|
||||
personality: '谨慎而果断',
|
||||
attributes: {
|
||||
strength: 8,
|
||||
spirit: 6,
|
||||
},
|
||||
skills: [{ id: 'skill-1' }],
|
||||
resourceProfile: {
|
||||
maxHp: 150,
|
||||
maxMana: 80,
|
||||
},
|
||||
},
|
||||
playerHp: 180,
|
||||
playerMaxHp: 120,
|
||||
playerMana: 22,
|
||||
playerMaxMana: 18,
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: {
|
||||
id: 'armor-1',
|
||||
category: '护甲',
|
||||
name: '试炼轻甲',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
tags: ['armor'],
|
||||
statProfile: {
|
||||
maxHpBonus: 20,
|
||||
},
|
||||
},
|
||||
relic: {
|
||||
id: 'relic-1',
|
||||
category: '饰品',
|
||||
name: '回气坠',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
tags: ['relic'],
|
||||
statProfile: {
|
||||
maxManaBonus: 15,
|
||||
},
|
||||
},
|
||||
},
|
||||
quests: [
|
||||
{
|
||||
id: 'quest-1',
|
||||
title: '试炼委托',
|
||||
summary: '完成一轮测试',
|
||||
description: '完成一轮测试',
|
||||
issuerNpcId: 'npc-1',
|
||||
issuerNpcName: '引路人',
|
||||
status: 'active',
|
||||
rewardText: '完成后可领取测试奖励。',
|
||||
reward: {
|
||||
currency: 10,
|
||||
items: [],
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
id: 'quest-1-step-1',
|
||||
title: '完成一轮测试',
|
||||
detail: '推进这条测试委托。',
|
||||
kind: 'reach_scene',
|
||||
targetSceneId: 'test-scene',
|
||||
requiredCount: 1,
|
||||
progress: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
roster: [
|
||||
{
|
||||
npcId: 'npc-companion',
|
||||
characterId: 'companion-a',
|
||||
joinedAtAffinity: 8,
|
||||
},
|
||||
],
|
||||
companions: [
|
||||
{
|
||||
npcId: 'npc-companion',
|
||||
characterId: 'companion-a',
|
||||
joinedAtAffinity: 8,
|
||||
},
|
||||
],
|
||||
npcStates: {
|
||||
npc_guard: {
|
||||
affinity: 12,
|
||||
revealedFacts: ['fact:a', 1],
|
||||
},
|
||||
},
|
||||
characterChats: {
|
||||
companion_a: {
|
||||
history: [
|
||||
{ speaker: 'player', text: '最近风声不对。' },
|
||||
{ speaker: 'npc', text: '这条不该留下。' },
|
||||
],
|
||||
summary: '已经建立起初步信任。',
|
||||
},
|
||||
},
|
||||
},
|
||||
currentStory: {
|
||||
text: '恢复中的故事',
|
||||
options: [],
|
||||
streaming: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(snapshot);
|
||||
assert.equal(snapshot.bottomTab, 'adventure');
|
||||
assert.equal(snapshot.currentStory?.streaming, false);
|
||||
assert.equal(snapshot.gameState.runtimeActionVersion, 0);
|
||||
assert.equal(snapshot.gameState.playerMaxHp, 170);
|
||||
assert.equal(snapshot.gameState.playerHp, 170);
|
||||
assert.equal(snapshot.gameState.playerMaxMana, 95);
|
||||
assert.equal(snapshot.gameState.playerMana, 22);
|
||||
assert.equal(snapshot.gameState.playerCurrency, 160);
|
||||
assert.deepEqual(snapshot.gameState.roster, []);
|
||||
assert.deepEqual(snapshot.gameState.storyEngineMemory.activeThreadIds, []);
|
||||
assert.equal(
|
||||
snapshot.gameState.storyEngineMemory.saveMigrationManifest?.version,
|
||||
'story-engine-v5',
|
||||
);
|
||||
assert.deepEqual(snapshot.gameState.npcStates.npc_guard.revealedFacts, [
|
||||
'fact:a',
|
||||
]);
|
||||
assert.deepEqual(snapshot.gameState.characterChats.companion_a.history, [
|
||||
{
|
||||
speaker: 'player',
|
||||
text: '最近风声不对。',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('runtime snapshot hydration keeps custom world economy defaults on the server', () => {
|
||||
const snapshot = hydrateSavedSnapshot({
|
||||
version: 2,
|
||||
savedAt: '2026-04-09T00:00:00.000Z',
|
||||
bottomTab: 'inventory',
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
customWorldProfile: {
|
||||
ownedSettingLayers: {
|
||||
ruleProfile: {
|
||||
economyProfile: {
|
||||
initialCurrency: 228,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
currentStory: null,
|
||||
});
|
||||
|
||||
assert.ok(snapshot);
|
||||
assert.equal(snapshot.bottomTab, 'inventory');
|
||||
assert.equal(snapshot.gameState.playerCurrency, 228);
|
||||
});
|
||||
|
||||
test('runtime snapshot hydration backfills starter loadout when legacy saves omitted playerEquipment', () => {
|
||||
const snapshot = hydrateSavedSnapshot({
|
||||
version: 2,
|
||||
savedAt: '2026-04-09T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {
|
||||
currentScene: 'Story',
|
||||
worldType: 'WUXIA',
|
||||
playerCharacter: {
|
||||
id: 'hero',
|
||||
title: '试剑客',
|
||||
description: '在风里试探局势的人。',
|
||||
personality: '谨慎而果断',
|
||||
attributes: {
|
||||
strength: 8,
|
||||
spirit: 6,
|
||||
},
|
||||
skills: [],
|
||||
},
|
||||
playerHp: 140,
|
||||
playerMaxHp: 140,
|
||||
playerMana: 60,
|
||||
playerMaxMana: 60,
|
||||
},
|
||||
currentStory: null,
|
||||
});
|
||||
|
||||
assert.ok(snapshot);
|
||||
assert.equal(snapshot.gameState.playerMaxHp, 208);
|
||||
assert.equal(snapshot.gameState.playerMaxMana, 1009);
|
||||
assert.equal(snapshot.gameState.playerEquipment.weapon?.id, 'starter:hero:weapon');
|
||||
assert.equal(snapshot.gameState.playerEquipment.armor?.id, 'starter:hero:armor');
|
||||
assert.equal(snapshot.gameState.playerEquipment.relic?.id, 'starter:hero:relic');
|
||||
});
|
||||
643
server-node/src/modules/runtime/runtimeSnapshotHydration.ts
Normal file
643
server-node/src/modules/runtime/runtimeSnapshotHydration.ts
Normal file
@@ -0,0 +1,643 @@
|
||||
import { jsonClone } from '../../http.js';
|
||||
import type { SavedSnapshot } from '../../repositories/runtimeRepository.js';
|
||||
import { normalizeQuestEntries } from '../quest/questProgressionService.js';
|
||||
import {
|
||||
createEmptyEquipmentLoadout,
|
||||
getEquipmentBonuses,
|
||||
getEquipmentSlotLabel,
|
||||
} from './runtimeEquipmentModule.js';
|
||||
import { normalizeNpcPersistentState } from './runtimeNpcStatePrimitives.js';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type SnapshotShape = {
|
||||
savedAt: string;
|
||||
bottomTab: unknown;
|
||||
gameState: unknown;
|
||||
currentStory: unknown;
|
||||
};
|
||||
|
||||
const STORY_ENGINE_MIGRATION_VERSION = 'story-engine-v5';
|
||||
const STORY_ENGINE_REQUIRED_TRANSFORMS = [
|
||||
'ensure_story_engine_memory',
|
||||
'ensure_campaign_state',
|
||||
'ensure_player_style_profile',
|
||||
];
|
||||
const UNIVERSAL_MAX_MANA = 999;
|
||||
const EQUIPMENT_SLOTS = ['weapon', 'armor', 'relic'] as const;
|
||||
|
||||
type RuntimeEquipmentSlotId = (typeof EQUIPMENT_SLOTS)[number];
|
||||
type LegacyCharacterEquipmentItem = {
|
||||
slot: string;
|
||||
item: string;
|
||||
rarity?: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(value: unknown, fallback = '') {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown, fallback = 0) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown, fallback = false) {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
function readArray(value: unknown) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function clampNonNegativeInteger(value: unknown) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.floor(value));
|
||||
}
|
||||
|
||||
function normalizeBottomTab(value: unknown) {
|
||||
return value === 'character' || value === 'inventory'
|
||||
? value
|
||||
: 'adventure';
|
||||
}
|
||||
|
||||
function buildSaveMigrationManifest() {
|
||||
return {
|
||||
version: 'story-engine-v5',
|
||||
requiredTransforms: [
|
||||
'ensure_story_engine_memory',
|
||||
'ensure_campaign_state',
|
||||
'ensure_player_style_profile',
|
||||
],
|
||||
backwardCompatible: true,
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyStoryEngineMemoryState() {
|
||||
return {
|
||||
discoveredFactIds: [],
|
||||
inferredFactIds: [],
|
||||
activeThreadIds: [],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
openedSceneChapterIds: [],
|
||||
recentSignalIds: [],
|
||||
recentCompanionReactions: [],
|
||||
currentChapter: null,
|
||||
currentJourneyBeatId: null,
|
||||
currentJourneyBeat: null,
|
||||
companionArcStates: [],
|
||||
worldMutations: [],
|
||||
chronicle: [],
|
||||
factionTensionStates: [],
|
||||
currentCampEvent: null,
|
||||
currentSetpieceDirective: null,
|
||||
continueGameDigest: null,
|
||||
campaignState: null,
|
||||
actState: null,
|
||||
consequenceLedger: [],
|
||||
companionResolutions: [],
|
||||
endingState: null,
|
||||
authorialConstraintPack: null,
|
||||
branchBudgetStatus: null,
|
||||
narrativeQaReport: null,
|
||||
narrativeCodex: [],
|
||||
playerStyleProfile: null,
|
||||
simulationRunResults: [],
|
||||
releaseGateReport: null,
|
||||
saveMigrationManifest: {
|
||||
version: STORY_ENGINE_MIGRATION_VERSION,
|
||||
requiredTransforms: STORY_ENGINE_REQUIRED_TRANSFORMS,
|
||||
backwardCompatible: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRuntimeStats(
|
||||
stats: unknown,
|
||||
options: {
|
||||
isActiveRun?: boolean;
|
||||
now?: number;
|
||||
} = {},
|
||||
) {
|
||||
const now = options.now ?? Date.now();
|
||||
const rawStats = isRecord(stats) ? stats : {};
|
||||
|
||||
return {
|
||||
playTimeMs:
|
||||
typeof rawStats.playTimeMs === 'number' &&
|
||||
Number.isFinite(rawStats.playTimeMs)
|
||||
? Math.max(0, rawStats.playTimeMs)
|
||||
: 0,
|
||||
lastPlayTickAt: options.isActiveRun ? new Date(now).toISOString() : null,
|
||||
hostileNpcsDefeated: clampNonNegativeInteger(
|
||||
rawStats.hostileNpcsDefeated,
|
||||
),
|
||||
questsAccepted: clampNonNegativeInteger(rawStats.questsAccepted),
|
||||
itemsUsed: clampNonNegativeInteger(rawStats.itemsUsed),
|
||||
scenesTraveled: clampNonNegativeInteger(rawStats.scenesTraveled),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCharacterChats(value: unknown) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(isRecord(value) ? value : {}).map(([characterId, record]) => {
|
||||
const rawRecord = isRecord(record) ? record : {};
|
||||
|
||||
return [
|
||||
characterId,
|
||||
{
|
||||
history: readArray(rawRecord.history)
|
||||
.filter(
|
||||
(turn) =>
|
||||
isRecord(turn) &&
|
||||
typeof turn.text === 'string' &&
|
||||
(turn.speaker === 'player' || turn.speaker === 'character'),
|
||||
)
|
||||
.map((turn) => ({
|
||||
speaker: turn.speaker,
|
||||
text: turn.text,
|
||||
})),
|
||||
summary: readString(rawRecord.summary),
|
||||
updatedAt: readString(rawRecord.updatedAt) || null,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCompanionState(value: unknown) {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const npcId = readString(value.npcId);
|
||||
if (!npcId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...jsonClone(value),
|
||||
npcId,
|
||||
characterId: readString(value.characterId),
|
||||
joinedAtAffinity: Math.round(readNumber(value.joinedAtAffinity, 0)),
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeCompanions(value: unknown) {
|
||||
const seenNpcIds = new Set<string>();
|
||||
|
||||
return readArray(value)
|
||||
.map((entry) => normalizeCompanionState(entry))
|
||||
.filter((entry): entry is NonNullable<ReturnType<typeof normalizeCompanionState>> => {
|
||||
if (!entry || seenNpcIds.has(entry.npcId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenNpcIds.add(entry.npcId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRoster(
|
||||
roster: ReturnType<typeof dedupeCompanions>,
|
||||
companions: ReturnType<typeof dedupeCompanions>,
|
||||
) {
|
||||
const activeNpcIds = new Set(companions.map((companion) => companion.npcId));
|
||||
|
||||
return roster.filter((companion) => !activeNpcIds.has(companion.npcId));
|
||||
}
|
||||
|
||||
function normalizeNpcStates(value: unknown) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(isRecord(value) ? value : {}).map(([npcId, npcState]) => {
|
||||
const rawState = isRecord(npcState) ? npcState : {};
|
||||
|
||||
return [
|
||||
npcId,
|
||||
normalizeNpcPersistentState({
|
||||
...jsonClone(rawState),
|
||||
affinity: Math.round(readNumber(rawState.affinity, 0)),
|
||||
chattedCount: Math.max(
|
||||
0,
|
||||
Math.round(readNumber(rawState.chattedCount, 0)),
|
||||
),
|
||||
helpUsed: readBoolean(rawState.helpUsed),
|
||||
giftsGiven: Math.max(
|
||||
0,
|
||||
Math.round(readNumber(rawState.giftsGiven, 0)),
|
||||
),
|
||||
inventory: jsonClone(readArray(rawState.inventory)),
|
||||
recruited: readBoolean(rawState.recruited),
|
||||
}),
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveInitialPlayerCurrency(gameState: JsonRecord) {
|
||||
const customWorldProfile = isRecord(gameState.customWorldProfile)
|
||||
? gameState.customWorldProfile
|
||||
: null;
|
||||
const customWorldInitialCurrency = readNumber(
|
||||
(customWorldProfile?.ownedSettingLayers as JsonRecord | undefined)
|
||||
?.ruleProfile &&
|
||||
isRecord(
|
||||
(customWorldProfile.ownedSettingLayers as JsonRecord).ruleProfile,
|
||||
) &&
|
||||
isRecord(
|
||||
(
|
||||
(customWorldProfile.ownedSettingLayers as JsonRecord)
|
||||
.ruleProfile as JsonRecord
|
||||
).economyProfile,
|
||||
)
|
||||
? (
|
||||
(
|
||||
(
|
||||
customWorldProfile.ownedSettingLayers as JsonRecord
|
||||
).ruleProfile as JsonRecord
|
||||
).economyProfile as JsonRecord
|
||||
).initialCurrency
|
||||
: undefined,
|
||||
Number.NaN,
|
||||
);
|
||||
if (Number.isFinite(customWorldInitialCurrency)) {
|
||||
return Math.max(0, Math.round(customWorldInitialCurrency));
|
||||
}
|
||||
|
||||
return readString(gameState.worldType).toUpperCase() === 'XIANXIA' ? 140 : 160;
|
||||
}
|
||||
|
||||
function normalizeEquipmentLoadout(value: unknown) {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
weapon: isRecord(value.weapon) ? jsonClone(value.weapon) : null,
|
||||
armor: isRecord(value.armor) ? jsonClone(value.armor) : null,
|
||||
relic: isRecord(value.relic) ? jsonClone(value.relic) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePresetRarity(rarityText: string | undefined) {
|
||||
if (!rarityText) return 'common' as const;
|
||||
if (/传说|legendary/iu.test(rarityText)) return 'legendary' as const;
|
||||
if (/史诗|epic/iu.test(rarityText)) return 'epic' as const;
|
||||
if (/稀有|rare/iu.test(rarityText)) return 'rare' as const;
|
||||
if (/优秀|uncommon/iu.test(rarityText)) return 'uncommon' as const;
|
||||
return 'common' as const;
|
||||
}
|
||||
|
||||
function inferEquipmentSlot(value: string) {
|
||||
if (/武器|剑|弓|刀|拳套|战刃|佩刀|枪|刃/u.test(value)) {
|
||||
return 'weapon' as const;
|
||||
}
|
||||
if (/护甲|甲|护臂|衣|袍|铠/u.test(value)) {
|
||||
return 'armor' as const;
|
||||
}
|
||||
if (/饰品|护符|徽章|玉|珠|坠|铃|盘|令|匣/u.test(value)) {
|
||||
return 'relic' as const;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferEquipmentTags(slot: RuntimeEquipmentSlotId, name: string) {
|
||||
const tags = new Set<string>([slot]);
|
||||
|
||||
if (/灵|气|符|珠|盘|玉/u.test(name)) tags.add('mana');
|
||||
if (/护|守|甲|铠/u.test(name)) tags.add('armor');
|
||||
if (/刃|剑|弓|刀|拳/u.test(name)) tags.add('weapon');
|
||||
if (/徽章|护符|坠|铃|盘|令/u.test(name)) tags.add('relic');
|
||||
if (/疗|愈|血/u.test(name)) tags.add('healing');
|
||||
|
||||
return [...tags];
|
||||
}
|
||||
|
||||
function getLegacyCharacterEquipment(character: JsonRecord): LegacyCharacterEquipmentItem[] {
|
||||
const equipmentById: Record<string, LegacyCharacterEquipmentItem[]> = {
|
||||
'sword-princess': [
|
||||
{ slot: '武器', item: '王庭剑', rarity: '稀有' },
|
||||
{ slot: '护甲', item: '王庭轻甲', rarity: '稀有' },
|
||||
{ slot: '饰品', item: '皇室徽章', rarity: '史诗' },
|
||||
],
|
||||
'archer-hero': [
|
||||
{ slot: '武器', item: '流风弓', rarity: '稀有' },
|
||||
{ slot: '护甲', item: '风行者皮甲', rarity: '稀有' },
|
||||
{ slot: '饰品', item: '鹰眼石', rarity: '史诗' },
|
||||
],
|
||||
'girl-hero': [
|
||||
{ slot: '武器', item: '双影刃', rarity: '稀有' },
|
||||
{ slot: '护甲', item: '疾影轻甲', rarity: '稀有' },
|
||||
{ slot: '饰品', item: '敏捷徽章', rarity: '史诗' },
|
||||
],
|
||||
'punch-hero': [
|
||||
{ slot: '武器', item: '破军拳套', rarity: '稀有' },
|
||||
{ slot: '护甲', item: '刚岩护甲', rarity: '稀有' },
|
||||
{ slot: '饰品', item: '力量护符', rarity: '史诗' },
|
||||
],
|
||||
'fighter-4': [
|
||||
{ slot: '武器', item: '玄甲战刃', rarity: '稀有' },
|
||||
{ slot: '护甲', item: '玄铁甲', rarity: '稀有' },
|
||||
{ slot: '饰品', item: '守护徽章', rarity: '史诗' },
|
||||
],
|
||||
};
|
||||
|
||||
const characterId = readString(character.id);
|
||||
if (equipmentById[characterId]) {
|
||||
return equipmentById[characterId];
|
||||
}
|
||||
|
||||
const characterName = readString(character.name, '旅人');
|
||||
return EQUIPMENT_SLOTS.map((slot) => ({
|
||||
slot: getEquipmentSlotLabel(slot),
|
||||
item: {
|
||||
weapon: `${characterName}的主手器`,
|
||||
armor: `${characterName}的护身装`,
|
||||
relic: `${characterName}的随身符`,
|
||||
}[slot],
|
||||
rarity: '普通',
|
||||
}));
|
||||
}
|
||||
|
||||
function buildLegacyStarterEquipmentLoadout(character: JsonRecord) {
|
||||
const characterId = readString(character.id, 'unknown');
|
||||
const loadout = createEmptyEquipmentLoadout();
|
||||
const starterEquipment = getLegacyCharacterEquipment(character);
|
||||
|
||||
starterEquipment.forEach((equipmentItem, index) => {
|
||||
const slot =
|
||||
inferEquipmentSlot(`${equipmentItem.slot} ${equipmentItem.item}`) ??
|
||||
EQUIPMENT_SLOTS[index] ??
|
||||
null;
|
||||
if (!slot || loadout[slot]) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadout[slot] = {
|
||||
id: `starter:${characterId}:${slot}`,
|
||||
category: getEquipmentSlotLabel(slot),
|
||||
name: equipmentItem.item,
|
||||
quantity: 1,
|
||||
rarity: normalizePresetRarity(equipmentItem.rarity),
|
||||
tags: inferEquipmentTags(slot, equipmentItem.item),
|
||||
equipmentSlotId: slot,
|
||||
};
|
||||
});
|
||||
|
||||
return loadout;
|
||||
}
|
||||
|
||||
function hasEquippedItems(
|
||||
equipment: ReturnType<typeof normalizeEquipmentLoadout>,
|
||||
) {
|
||||
return Boolean(equipment?.weapon || equipment?.armor || equipment?.relic);
|
||||
}
|
||||
|
||||
function readCharacterAttributes(character: JsonRecord) {
|
||||
return isRecord(character.attributes) ? character.attributes : {};
|
||||
}
|
||||
|
||||
function getLegacyCharacterBaseMaxHp(character: JsonRecord) {
|
||||
const attributes = readCharacterAttributes(character);
|
||||
|
||||
return Math.max(
|
||||
120,
|
||||
90 +
|
||||
readNumber(attributes.strength, 0) * 10 +
|
||||
readNumber(attributes.spirit, 0) * 4,
|
||||
);
|
||||
}
|
||||
|
||||
function buildCharacterResourceProfile(character: JsonRecord) {
|
||||
const resourceProfile = isRecord(character.resourceProfile)
|
||||
? character.resourceProfile
|
||||
: null;
|
||||
if (
|
||||
resourceProfile &&
|
||||
Number.isFinite(resourceProfile.maxHp) &&
|
||||
Number.isFinite(resourceProfile.maxMana)
|
||||
) {
|
||||
return {
|
||||
maxHp: Math.max(1, Math.round(readNumber(resourceProfile.maxHp, 1))),
|
||||
maxMana: Math.max(
|
||||
1,
|
||||
Math.round(readNumber(resourceProfile.maxMana, UNIVERSAL_MAX_MANA)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const source = [
|
||||
readString(character.title),
|
||||
readString(character.description),
|
||||
readString(character.personality),
|
||||
...readArray(character.combatTags).filter(
|
||||
(tag): tag is string => typeof tag === 'string' && tag.trim().length > 0,
|
||||
),
|
||||
].join(' ');
|
||||
const skills = readArray(character.skills);
|
||||
const baseHp = /守|甲|拳|先锋|重击|护体/u.test(source)
|
||||
? 210
|
||||
: /远射|机动|快袭|游击/u.test(source)
|
||||
? 168
|
||||
: /法|符|阵|灵|术/u.test(source)
|
||||
? 176
|
||||
: 188;
|
||||
|
||||
return {
|
||||
maxHp: Math.max(
|
||||
getLegacyCharacterBaseMaxHp(character),
|
||||
baseHp + Math.min(18, skills.length * 4),
|
||||
),
|
||||
maxMana: UNIVERSAL_MAX_MANA,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSavedStory(currentStory: unknown) {
|
||||
if (!isRecord(currentStory)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...jsonClone(currentStory),
|
||||
streaming: false,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGameState(gameState: unknown) {
|
||||
const rawState = isRecord(gameState) ? jsonClone(gameState) : {};
|
||||
const { playerEquipment: _rawPlayerEquipment, ...rawStateWithoutEquipment } =
|
||||
rawState;
|
||||
const playerCharacter = isRecord(rawState.playerCharacter)
|
||||
? rawState.playerCharacter
|
||||
: null;
|
||||
const companions = dedupeCompanions(rawState.companions);
|
||||
const roster = normalizeRoster(dedupeCompanions(rawState.roster), companions);
|
||||
const storyEngineMemory = {
|
||||
...createEmptyStoryEngineMemoryState(),
|
||||
...(isRecord(rawState.storyEngineMemory)
|
||||
? jsonClone(rawState.storyEngineMemory)
|
||||
: {}),
|
||||
saveMigrationManifest: buildSaveMigrationManifest(),
|
||||
};
|
||||
const savedPlayerMaxHp = Math.max(
|
||||
1,
|
||||
Math.round(readNumber(rawState.playerMaxHp, 1)),
|
||||
);
|
||||
const savedPlayerMaxMana = Math.max(
|
||||
1,
|
||||
Math.round(readNumber(rawState.playerMaxMana, 1)),
|
||||
);
|
||||
const resolvedEquipment =
|
||||
normalizeEquipmentLoadout(rawState.playerEquipment) ??
|
||||
(playerCharacter ? buildLegacyStarterEquipmentLoadout(playerCharacter) : null);
|
||||
const baseResourceProfile = playerCharacter
|
||||
? buildCharacterResourceProfile(playerCharacter)
|
||||
: null;
|
||||
const basePlayerMaxHp = baseResourceProfile
|
||||
? hasEquippedItems(resolvedEquipment)
|
||||
? baseResourceProfile.maxHp
|
||||
: Math.max(baseResourceProfile.maxHp, savedPlayerMaxHp)
|
||||
: savedPlayerMaxHp;
|
||||
const basePlayerMaxMana = baseResourceProfile
|
||||
? hasEquippedItems(resolvedEquipment)
|
||||
? baseResourceProfile.maxMana
|
||||
: Math.max(baseResourceProfile.maxMana, savedPlayerMaxMana)
|
||||
: savedPlayerMaxMana;
|
||||
const normalizedCommonState = {
|
||||
...rawStateWithoutEquipment,
|
||||
customWorldProfile:
|
||||
isRecord(rawState.customWorldProfile) || rawState.customWorldProfile === null
|
||||
? rawState.customWorldProfile ?? null
|
||||
: null,
|
||||
runtimeStats: normalizeRuntimeStats(rawState.runtimeStats, {
|
||||
isActiveRun: Boolean(
|
||||
rawState.playerCharacter && rawState.currentScene === 'Story',
|
||||
),
|
||||
}),
|
||||
storyEngineMemory,
|
||||
chapterState:
|
||||
rawState.chapterState ??
|
||||
(isRecord(storyEngineMemory.currentChapter)
|
||||
? storyEngineMemory.currentChapter
|
||||
: null),
|
||||
campaignState:
|
||||
rawState.campaignState ??
|
||||
(isRecord(storyEngineMemory.campaignState)
|
||||
? storyEngineMemory.campaignState
|
||||
: storyEngineMemory.campaignState ?? null),
|
||||
activeScenarioPackId:
|
||||
readString(rawState.activeScenarioPackId) ||
|
||||
readString(
|
||||
(rawState.customWorldProfile as JsonRecord | null)?.scenarioPackId,
|
||||
) ||
|
||||
null,
|
||||
activeCampaignPackId:
|
||||
readString(rawState.activeCampaignPackId) ||
|
||||
readString(
|
||||
(rawState.customWorldProfile as JsonRecord | null)?.campaignPackId,
|
||||
) ||
|
||||
null,
|
||||
npcInteractionActive: readBoolean(rawState.npcInteractionActive),
|
||||
playerCurrency:
|
||||
typeof rawState.playerCurrency === 'number' &&
|
||||
Number.isFinite(rawState.playerCurrency)
|
||||
? Math.round(rawState.playerCurrency)
|
||||
: resolveInitialPlayerCurrency(rawState),
|
||||
quests: normalizeQuestEntries(
|
||||
jsonClone(readArray(rawState.quests)) as Parameters<
|
||||
typeof normalizeQuestEntries
|
||||
>[0],
|
||||
),
|
||||
roster,
|
||||
companions,
|
||||
npcStates: normalizeNpcStates(rawState.npcStates),
|
||||
characterChats: normalizeCharacterChats(rawState.characterChats),
|
||||
activeBuildBuffs: jsonClone(readArray(rawState.activeBuildBuffs)),
|
||||
runtimeSessionId: readString(rawState.runtimeSessionId) || null,
|
||||
runtimeActionVersion:
|
||||
typeof rawState.runtimeActionVersion === 'number' &&
|
||||
Number.isFinite(rawState.runtimeActionVersion)
|
||||
? Math.round(rawState.runtimeActionVersion)
|
||||
: 0,
|
||||
};
|
||||
|
||||
if (!playerCharacter) {
|
||||
return {
|
||||
...normalizedCommonState,
|
||||
playerEquipment: createEmptyEquipmentLoadout(),
|
||||
playerMaxHp: savedPlayerMaxHp,
|
||||
playerHp: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
savedPlayerMaxHp,
|
||||
Math.round(readNumber(rawState.playerHp, savedPlayerMaxHp)),
|
||||
),
|
||||
),
|
||||
playerMaxMana: savedPlayerMaxMana,
|
||||
playerMana: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
savedPlayerMaxMana,
|
||||
Math.round(readNumber(rawState.playerMana, savedPlayerMaxMana)),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const stateWithResourceCaps = {
|
||||
...normalizedCommonState,
|
||||
playerCharacter,
|
||||
playerMaxHp: basePlayerMaxHp,
|
||||
playerHp: Math.max(
|
||||
0,
|
||||
Math.round(readNumber(rawState.playerHp, basePlayerMaxHp)),
|
||||
),
|
||||
playerMaxMana: basePlayerMaxMana,
|
||||
playerMana: Math.max(
|
||||
0,
|
||||
Math.round(readNumber(rawState.playerMana, basePlayerMaxMana)),
|
||||
),
|
||||
};
|
||||
|
||||
if (!resolvedEquipment) {
|
||||
return stateWithResourceCaps;
|
||||
}
|
||||
|
||||
const equipmentBonuses = getEquipmentBonuses(resolvedEquipment);
|
||||
const nextPlayerMaxHp = basePlayerMaxHp + equipmentBonuses.maxHpBonus;
|
||||
const nextPlayerMaxMana = basePlayerMaxMana + equipmentBonuses.maxManaBonus;
|
||||
|
||||
return {
|
||||
...stateWithResourceCaps,
|
||||
playerEquipment: resolvedEquipment,
|
||||
playerMaxHp: nextPlayerMaxHp,
|
||||
playerHp: Math.min(nextPlayerMaxHp, stateWithResourceCaps.playerHp),
|
||||
playerMaxMana: nextPlayerMaxMana,
|
||||
playerMana: Math.min(nextPlayerMaxMana, stateWithResourceCaps.playerMana),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSavedSnapshotPayload<T extends SnapshotShape>(snapshot: T) {
|
||||
return {
|
||||
...snapshot,
|
||||
bottomTab: normalizeBottomTab(snapshot.bottomTab),
|
||||
gameState: normalizeGameState(snapshot.gameState),
|
||||
currentStory: normalizeSavedStory(snapshot.currentStory),
|
||||
};
|
||||
}
|
||||
|
||||
export function hydrateSavedSnapshot(
|
||||
snapshot: SavedSnapshot | null,
|
||||
): SavedSnapshot | null {
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeSavedSnapshotPayload(snapshot);
|
||||
}
|
||||
113
server-node/src/modules/runtime/runtimeStatePrimitives.test.ts
Normal file
113
server-node/src/modules/runtime/runtimeStatePrimitives.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
addInventoryItems,
|
||||
buildRelationState,
|
||||
incrementGameRuntimeStats,
|
||||
removeInventoryItem,
|
||||
} from './runtimeStatePrimitives.js';
|
||||
|
||||
test('runtime state primitives merge stackable inventory items but preserve identity-sensitive items', () => {
|
||||
const merged = addInventoryItems(
|
||||
[
|
||||
{
|
||||
id: 'potion-1',
|
||||
category: '消耗品',
|
||||
name: '疗伤丹',
|
||||
quantity: 1,
|
||||
rarity: 'uncommon',
|
||||
tags: ['healing'],
|
||||
},
|
||||
{
|
||||
id: 'relic-1',
|
||||
category: '专属物品',
|
||||
name: '青铜令牌',
|
||||
quantity: 1,
|
||||
rarity: 'epic',
|
||||
tags: ['relic'],
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: 'potion-2',
|
||||
category: '消耗品',
|
||||
name: '疗伤丹',
|
||||
quantity: 2,
|
||||
rarity: 'uncommon',
|
||||
tags: ['healing'],
|
||||
},
|
||||
{
|
||||
id: 'relic-2',
|
||||
category: '专属物品',
|
||||
name: '青铜令牌',
|
||||
quantity: 1,
|
||||
rarity: 'epic',
|
||||
tags: ['relic'],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
merged.find((item) => item.name === '疗伤丹')?.quantity,
|
||||
3,
|
||||
);
|
||||
assert.equal(
|
||||
merged.filter((item) => item.name === '青铜令牌').length,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test('runtime state primitives remove inventory quantity without leaving zero-count entries', () => {
|
||||
const nextInventory = removeInventoryItem(
|
||||
[
|
||||
{
|
||||
id: 'potion-1',
|
||||
category: '消耗品',
|
||||
name: '疗伤丹',
|
||||
quantity: 2,
|
||||
rarity: 'uncommon',
|
||||
tags: ['healing'],
|
||||
},
|
||||
],
|
||||
'potion-1',
|
||||
2,
|
||||
);
|
||||
|
||||
assert.deepEqual(nextInventory, []);
|
||||
});
|
||||
|
||||
test('runtime state primitives increment stats and resolve relation stances locally on the server', () => {
|
||||
const nextStats = incrementGameRuntimeStats(
|
||||
{
|
||||
hostileNpcsDefeated: 1,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 2,
|
||||
scenesTraveled: 3,
|
||||
},
|
||||
{
|
||||
questsAccepted: 2,
|
||||
itemsUsed: -1,
|
||||
scenesTraveled: 4,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(nextStats, {
|
||||
hostileNpcsDefeated: 1,
|
||||
questsAccepted: 2,
|
||||
itemsUsed: 2,
|
||||
scenesTraveled: 7,
|
||||
});
|
||||
assert.deepEqual(buildRelationState(-5), {
|
||||
affinity: -5,
|
||||
stance: 'hostile',
|
||||
});
|
||||
assert.deepEqual(buildRelationState(18), {
|
||||
affinity: 18,
|
||||
stance: 'neutral',
|
||||
});
|
||||
assert.deepEqual(buildRelationState(72), {
|
||||
affinity: 72,
|
||||
stance: 'bonded',
|
||||
});
|
||||
});
|
||||
221
server-node/src/modules/runtime/runtimeStatePrimitives.ts
Normal file
221
server-node/src/modules/runtime/runtimeStatePrimitives.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
type RuntimeInventoryBuildBuff = {
|
||||
name: string;
|
||||
durationTurns: number;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
type RuntimeInventoryUseProfile = {
|
||||
hpRestore?: number;
|
||||
manaRestore?: number;
|
||||
cooldownReduction?: number;
|
||||
buildBuffs?: RuntimeInventoryBuildBuff[];
|
||||
};
|
||||
|
||||
type RuntimeInventoryItemLike = {
|
||||
id: string;
|
||||
category: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
rarity?: string | null;
|
||||
tags: string[];
|
||||
runtimeMetadata?: unknown;
|
||||
equipmentSlotId?: unknown;
|
||||
buildProfile?: unknown;
|
||||
statProfile?: unknown;
|
||||
attributeResonance?: unknown;
|
||||
useProfile?: RuntimeInventoryUseProfile | null;
|
||||
};
|
||||
|
||||
type RuntimeStatsLike = {
|
||||
hostileNpcsDefeated: number;
|
||||
questsAccepted: number;
|
||||
itemsUsed: number;
|
||||
scenesTraveled: number;
|
||||
};
|
||||
|
||||
type RuntimeRelationState = {
|
||||
affinity: number;
|
||||
stance: 'hostile' | 'guarded' | 'neutral' | 'cooperative' | 'bonded';
|
||||
};
|
||||
|
||||
const RARITY_SCORES: Record<string, number> = {
|
||||
common: 1,
|
||||
uncommon: 2,
|
||||
rare: 3,
|
||||
epic: 4,
|
||||
legendary: 5,
|
||||
};
|
||||
|
||||
function clampNonNegativeInteger(value: unknown) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.floor(value));
|
||||
}
|
||||
|
||||
function getRarityScore(rarity: string | null | undefined) {
|
||||
if (!rarity) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return RARITY_SCORES[rarity] ?? 0;
|
||||
}
|
||||
|
||||
function isIdentitySensitiveInventoryItem(item: RuntimeInventoryItemLike) {
|
||||
return Boolean(
|
||||
item.runtimeMetadata ||
|
||||
item.equipmentSlotId ||
|
||||
item.buildProfile ||
|
||||
item.statProfile ||
|
||||
item.attributeResonance ||
|
||||
item.category.includes('专属') ||
|
||||
item.rarity === 'epic' ||
|
||||
item.rarity === 'legendary',
|
||||
);
|
||||
}
|
||||
|
||||
function buildInventoryMergeKey(item: RuntimeInventoryItemLike) {
|
||||
if (isIdentitySensitiveInventoryItem(item)) {
|
||||
return `identity:${item.id}`;
|
||||
}
|
||||
|
||||
const buildBuffKey = (item.useProfile?.buildBuffs ?? [])
|
||||
.map(
|
||||
(buff) =>
|
||||
`${buff.name}:${buff.durationTurns}:${(buff.tags ?? []).join('|')}`,
|
||||
)
|
||||
.join(',');
|
||||
|
||||
return [
|
||||
item.category,
|
||||
item.name,
|
||||
item.rarity ?? '',
|
||||
[...(item.tags ?? [])].sort().join('|'),
|
||||
item.useProfile?.hpRestore ?? 0,
|
||||
item.useProfile?.manaRestore ?? 0,
|
||||
item.useProfile?.cooldownReduction ?? 0,
|
||||
buildBuffKey,
|
||||
].join('::');
|
||||
}
|
||||
|
||||
function mergeInventory<TItem extends RuntimeInventoryItemLike>(items: TItem[]) {
|
||||
const merged = new Map<string, TItem>();
|
||||
|
||||
for (const item of items) {
|
||||
const key = buildInventoryMergeKey(item);
|
||||
const existing = merged.get(key);
|
||||
if (existing) {
|
||||
merged.set(key, {
|
||||
...existing,
|
||||
quantity: existing.quantity + item.quantity,
|
||||
tags: [...new Set([...(existing.tags ?? []), ...(item.tags ?? [])])],
|
||||
runtimeMetadata:
|
||||
existing.runtimeMetadata ?? item.runtimeMetadata ?? null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
merged.set(key, {
|
||||
...item,
|
||||
tags: [...new Set(item.tags ?? [])],
|
||||
});
|
||||
}
|
||||
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
export function sortInventoryItems<TItem extends RuntimeInventoryItemLike>(
|
||||
items: TItem[],
|
||||
) {
|
||||
return [...items].sort((left, right) => {
|
||||
const rarityDiff = getRarityScore(right.rarity) - getRarityScore(left.rarity);
|
||||
if (rarityDiff !== 0) {
|
||||
return rarityDiff;
|
||||
}
|
||||
|
||||
const categoryDiff = left.category.localeCompare(
|
||||
right.category,
|
||||
'zh-Hans-CN',
|
||||
);
|
||||
if (categoryDiff !== 0) {
|
||||
return categoryDiff;
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name, 'zh-Hans-CN');
|
||||
});
|
||||
}
|
||||
|
||||
export function addInventoryItems<TItem extends RuntimeInventoryItemLike>(
|
||||
base: TItem[],
|
||||
additions: TItem[],
|
||||
) {
|
||||
return sortInventoryItems(mergeInventory([...base, ...additions]));
|
||||
}
|
||||
|
||||
export function removeInventoryItem<TItem extends RuntimeInventoryItemLike>(
|
||||
base: TItem[],
|
||||
itemId: string,
|
||||
quantity = 1,
|
||||
) {
|
||||
return sortInventoryItems(
|
||||
base
|
||||
.map((item) =>
|
||||
item.id === itemId
|
||||
? {
|
||||
...item,
|
||||
quantity: Math.max(0, item.quantity - quantity),
|
||||
}
|
||||
: item,
|
||||
)
|
||||
.filter((item) => item.quantity > 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function incrementGameRuntimeStats<TStats extends RuntimeStatsLike>(
|
||||
stats: TStats,
|
||||
increments: Partial<
|
||||
Pick<
|
||||
RuntimeStatsLike,
|
||||
'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'
|
||||
>
|
||||
>,
|
||||
) {
|
||||
return {
|
||||
...stats,
|
||||
hostileNpcsDefeated:
|
||||
stats.hostileNpcsDefeated +
|
||||
clampNonNegativeInteger(increments.hostileNpcsDefeated),
|
||||
questsAccepted:
|
||||
stats.questsAccepted + clampNonNegativeInteger(increments.questsAccepted),
|
||||
itemsUsed: stats.itemsUsed + clampNonNegativeInteger(increments.itemsUsed),
|
||||
scenesTraveled:
|
||||
stats.scenesTraveled +
|
||||
clampNonNegativeInteger(increments.scenesTraveled),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRelationStance(
|
||||
affinity: number,
|
||||
): RuntimeRelationState['stance'] {
|
||||
if (affinity < 0) {
|
||||
return 'hostile';
|
||||
}
|
||||
if (affinity < 15) {
|
||||
return 'guarded';
|
||||
}
|
||||
if (affinity < 30) {
|
||||
return 'neutral';
|
||||
}
|
||||
if (affinity < 60) {
|
||||
return 'cooperative';
|
||||
}
|
||||
return 'bonded';
|
||||
}
|
||||
|
||||
export function buildRelationState(affinity: number): RuntimeRelationState {
|
||||
return {
|
||||
affinity,
|
||||
stance: resolveRelationStance(affinity),
|
||||
};
|
||||
}
|
||||
49
server-node/src/modules/runtime/runtimeTreasureTexts.ts
Normal file
49
server-node/src/modules/runtime/runtimeTreasureTexts.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { formatCurrency } from './runtimeEconomyPrimitives.js';
|
||||
|
||||
type TreasureRewardItem = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
type TreasureRewardLike = {
|
||||
items: TreasureRewardItem[];
|
||||
hp: number;
|
||||
mana: number;
|
||||
currency: number;
|
||||
storyHint?: string;
|
||||
};
|
||||
|
||||
type TreasureEncounterLike = {
|
||||
npcName: string;
|
||||
};
|
||||
|
||||
type TreasureInteractionAction = 'inspect' | 'leave' | 'secure';
|
||||
|
||||
export function buildTreasureResultText(
|
||||
encounter: TreasureEncounterLike,
|
||||
action: TreasureInteractionAction,
|
||||
reward?: TreasureRewardLike,
|
||||
worldType?: string | null,
|
||||
) {
|
||||
if (action === 'leave') {
|
||||
return `你暂时没有触碰 ${encounter.npcName},只是把它的异常位置和痕迹牢牢记下。`;
|
||||
}
|
||||
|
||||
const itemText =
|
||||
reward?.items.length ? reward.items.map((item) => item.name).join('、') : '零散战利品';
|
||||
const restoreParts = [
|
||||
(reward?.hp ?? 0) > 0 ? `气血 +${reward?.hp ?? 0}` : null,
|
||||
(reward?.mana ?? 0) > 0 ? `灵力 +${reward?.mana ?? 0}` : null,
|
||||
].filter(Boolean);
|
||||
const restoreText =
|
||||
restoreParts.length > 0 ? `,并恢复 ${restoreParts.join('、')}` : '';
|
||||
const currencyText = reward
|
||||
? `,另得 ${formatCurrency(reward.currency, worldType ?? null)}`
|
||||
: '';
|
||||
const storyHint = reward?.storyHint ? ` ${reward.storyHint}` : '';
|
||||
|
||||
if (action === 'inspect') {
|
||||
return `你仔细检查了 ${encounter.npcName},顺着现场痕迹拆开机关与伪装,最终收回 ${itemText}${currencyText}${restoreText}。${storyHint}`;
|
||||
}
|
||||
|
||||
return `你迅速收下了 ${encounter.npcName} 中最关键的收获:${itemText}${currencyText}。${storyHint}`;
|
||||
}
|
||||
854
server-node/src/modules/story/runtimeSession.ts
Normal file
854
server-node/src/modules/story/runtimeSession.ts
Normal file
@@ -0,0 +1,854 @@
|
||||
import type {
|
||||
RuntimeStoryEncounterViewModel,
|
||||
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';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type StoryHistoryRole = 'action' | 'result';
|
||||
|
||||
type FunctionDefinition = {
|
||||
actionText: string;
|
||||
detailText: string;
|
||||
scope: Task5RuntimeOptionScope;
|
||||
};
|
||||
|
||||
export type RuntimeStoryHistoryEntry = {
|
||||
text: string;
|
||||
historyRole: StoryHistoryRole;
|
||||
};
|
||||
|
||||
export type RuntimeNpcState = {
|
||||
affinity: number;
|
||||
chattedCount: number;
|
||||
helpUsed: boolean;
|
||||
giftsGiven: number;
|
||||
inventory: unknown[];
|
||||
recruited: boolean;
|
||||
firstMeaningfulContactResolved: boolean;
|
||||
relationState: JsonRecord | null;
|
||||
stanceProfile: JsonRecord | null;
|
||||
tradeStockSignature?: string | null;
|
||||
revealedFacts?: string[];
|
||||
knownAttributeRumors?: string[];
|
||||
seenBackstoryChapterIds?: string[];
|
||||
};
|
||||
|
||||
export type RuntimeEncounter = {
|
||||
id: string;
|
||||
kind: 'npc' | 'treasure';
|
||||
npcName: string;
|
||||
npcDescription: string;
|
||||
context: string;
|
||||
hostile: boolean;
|
||||
characterId: string | null;
|
||||
monsterPresetId: string | null;
|
||||
};
|
||||
|
||||
export type RuntimeHostileNpc = {
|
||||
id: string;
|
||||
name: string;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type RuntimeCompanion = {
|
||||
npcId: string;
|
||||
characterId: string;
|
||||
joinedAtAffinity: number;
|
||||
};
|
||||
|
||||
export type RuntimeSession = {
|
||||
sessionId: string;
|
||||
runtimeVersion: number;
|
||||
snapshotBottomTab: string;
|
||||
rawGameState: JsonRecord;
|
||||
worldType: string | null;
|
||||
storyHistory: RuntimeStoryHistoryEntry[];
|
||||
currentEncounter: RuntimeEncounter | null;
|
||||
npcInteractionActive: boolean;
|
||||
sceneHostileNpcs: RuntimeHostileNpc[];
|
||||
inBattle: boolean;
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
playerMana: number;
|
||||
playerMaxMana: number;
|
||||
npcStates: Record<string, RuntimeNpcState>;
|
||||
companions: RuntimeCompanion[];
|
||||
currentNpcBattleMode: 'fight' | 'spar' | null;
|
||||
currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null;
|
||||
};
|
||||
|
||||
export const MAX_TASK5_COMPANIONS = 2;
|
||||
|
||||
const STORY_FUNCTION_IDS = new Set<string>([
|
||||
'story_continue_adventure',
|
||||
'story_opening_camp_dialogue',
|
||||
'camp_travel_home_scene',
|
||||
'idle_call_out',
|
||||
'idle_explore_forward',
|
||||
'idle_observe_signs',
|
||||
'idle_rest_focus',
|
||||
'idle_travel_next_scene',
|
||||
]);
|
||||
|
||||
const COMBAT_FUNCTION_IDS = new Set<string>([
|
||||
'battle_all_in_crush',
|
||||
'battle_escape_breakout',
|
||||
'battle_feint_step',
|
||||
'battle_finisher_window',
|
||||
'battle_guard_break',
|
||||
'battle_probe_pressure',
|
||||
'battle_recover_breath',
|
||||
]);
|
||||
|
||||
const NPC_FUNCTION_IDS = new Set<string>([
|
||||
'npc_chat',
|
||||
'npc_fight',
|
||||
'npc_help',
|
||||
'npc_leave',
|
||||
'npc_preview_talk',
|
||||
'npc_recruit',
|
||||
'npc_spar',
|
||||
]);
|
||||
|
||||
const TASK6_RUNTIME_FUNCTION_ID_SET = new Set<string>(
|
||||
TASK6_RUNTIME_FUNCTION_IDS,
|
||||
);
|
||||
|
||||
export const TASK6_DEFERRED_FUNCTION_IDS = new Set<string>([
|
||||
]);
|
||||
|
||||
const FUNCTION_DEFINITIONS: Record<string, FunctionDefinition> = {
|
||||
story_continue_adventure: {
|
||||
actionText: '继续推进冒险',
|
||||
detailText: '让后端基于当前快照继续推进当前故事状态。',
|
||||
scope: 'story',
|
||||
},
|
||||
story_opening_camp_dialogue: {
|
||||
actionText: '交换开场判断',
|
||||
detailText: '把当前营地里的第一次正式对话切进服务端交互态。',
|
||||
scope: 'story',
|
||||
},
|
||||
camp_travel_home_scene: {
|
||||
actionText: '返回营地',
|
||||
detailText: '结束当前遭遇,把流程带回安全的营地状态。',
|
||||
scope: 'story',
|
||||
},
|
||||
idle_call_out: {
|
||||
actionText: '主动出声试探',
|
||||
detailText: '对前路喊话,逼迫附近的动静更快浮出水面。',
|
||||
scope: 'story',
|
||||
},
|
||||
idle_explore_forward: {
|
||||
actionText: '继续向前探索',
|
||||
detailText: '继续沿当前路径深入,把新遭遇交给后端推进。',
|
||||
scope: 'story',
|
||||
},
|
||||
idle_observe_signs: {
|
||||
actionText: '观察周围迹象',
|
||||
detailText: '先读环境,再决定下一轮要不要靠近或出手。',
|
||||
scope: 'story',
|
||||
},
|
||||
idle_rest_focus: {
|
||||
actionText: '原地调息',
|
||||
detailText: '恢复少量生命与灵力,稳住下一轮节奏。',
|
||||
scope: 'story',
|
||||
},
|
||||
idle_travel_next_scene: {
|
||||
actionText: '前往相邻场景',
|
||||
detailText: '收束当前遭遇并切往下一段场景流程。',
|
||||
scope: 'story',
|
||||
},
|
||||
battle_all_in_crush: {
|
||||
actionText: '正面强压',
|
||||
detailText: '高消耗高压制,适合趁敌人还没稳住时硬顶上去。',
|
||||
scope: 'combat',
|
||||
},
|
||||
battle_escape_breakout: {
|
||||
actionText: '强行脱离战斗',
|
||||
detailText: '打断当前战斗,把状态切回探索或脱身结果。',
|
||||
scope: 'combat',
|
||||
},
|
||||
battle_feint_step: {
|
||||
actionText: '虚晃切步',
|
||||
detailText: '用更轻的代价制造伤害,同时压低敌方反击力度。',
|
||||
scope: 'combat',
|
||||
},
|
||||
battle_finisher_window: {
|
||||
actionText: '抓破绽终结',
|
||||
detailText: '对残血目标有额外收益,适合收尾。',
|
||||
scope: 'combat',
|
||||
},
|
||||
battle_guard_break: {
|
||||
actionText: '破架重击',
|
||||
detailText: '偏稳定的伤害动作,能打断对方的站稳节奏。',
|
||||
scope: 'combat',
|
||||
},
|
||||
battle_probe_pressure: {
|
||||
actionText: '稳步试探',
|
||||
detailText: '低风险压迫,兼顾伤害和节奏控制。',
|
||||
scope: 'combat',
|
||||
},
|
||||
battle_recover_breath: {
|
||||
actionText: '边守边调息',
|
||||
detailText: '优先回稳资源,但仍可能吃到轻量反击。',
|
||||
scope: 'combat',
|
||||
},
|
||||
npc_chat: {
|
||||
actionText: '继续交谈',
|
||||
detailText: '围绕当前话题延续对话,推进好感与关系判断。',
|
||||
scope: 'npc',
|
||||
},
|
||||
npc_fight: {
|
||||
actionText: '与对方战斗',
|
||||
detailText: '把当前 NPC 交互直接切进正式战斗结算。',
|
||||
scope: 'npc',
|
||||
},
|
||||
npc_help: {
|
||||
actionText: '请求援手',
|
||||
detailText: '向当前 NPC 请求一次性支援,恢复部分状态。',
|
||||
scope: 'npc',
|
||||
},
|
||||
npc_leave: {
|
||||
actionText: '离开当前角色',
|
||||
detailText: '结束当前 NPC 交互,重新回到探索态。',
|
||||
scope: 'npc',
|
||||
},
|
||||
npc_preview_talk: {
|
||||
actionText: '转向眼前角色',
|
||||
detailText: '从遭遇预览切进正式 NPC 互动菜单。',
|
||||
scope: 'npc',
|
||||
},
|
||||
npc_recruit: {
|
||||
actionText: '邀请加入队伍',
|
||||
detailText: '关系达标后可以直接把当前 NPC 收进同行队伍。',
|
||||
scope: 'npc',
|
||||
},
|
||||
npc_spar: {
|
||||
actionText: '点到为止切磋',
|
||||
detailText: '用 spar 模式进入轻量战斗,结果会回流到关系状态。',
|
||||
scope: 'npc',
|
||||
},
|
||||
npc_trade: {
|
||||
actionText: '交易',
|
||||
detailText: '查看库存并执行买入或卖出。',
|
||||
scope: 'npc',
|
||||
},
|
||||
npc_gift: {
|
||||
actionText: '赠送礼物',
|
||||
detailText: '把背包里的物品正式交给当前角色。',
|
||||
scope: 'npc',
|
||||
},
|
||||
npc_quest_accept: {
|
||||
actionText: '接下委托',
|
||||
detailText: '把当前角色的委托正式收进任务日志。',
|
||||
scope: 'npc',
|
||||
},
|
||||
npc_quest_turn_in: {
|
||||
actionText: '交付委托',
|
||||
detailText: '向当前角色结算已经完成的委托奖励。',
|
||||
scope: 'npc',
|
||||
},
|
||||
treasure_secure: {
|
||||
actionText: '直接收取',
|
||||
detailText: '不再拖延,直接把眼前最关键的收获带走。',
|
||||
scope: 'story',
|
||||
},
|
||||
treasure_inspect: {
|
||||
actionText: '仔细检查',
|
||||
detailText: '多花些时间拆开机关、痕迹和伪装。',
|
||||
scope: 'story',
|
||||
},
|
||||
treasure_leave: {
|
||||
actionText: '先记下位置',
|
||||
detailText: '暂时不碰它,只把异常位置和痕迹记住。',
|
||||
scope: 'story',
|
||||
},
|
||||
};
|
||||
|
||||
function cloneJson<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is JsonRecord {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(value: unknown, fallback = '') {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown, fallback = 0) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown, fallback = false) {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
function readArray(value: unknown) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function normalizeStoryHistory(value: unknown) {
|
||||
return readArray(value)
|
||||
.map((entry) => {
|
||||
const rawEntry = isObject(entry) ? entry : {};
|
||||
const historyRole =
|
||||
rawEntry.historyRole === 'action' ? 'action' : 'result';
|
||||
|
||||
return {
|
||||
text: readString(rawEntry.text),
|
||||
historyRole,
|
||||
} satisfies RuntimeStoryHistoryEntry;
|
||||
})
|
||||
.filter((entry) => entry.text);
|
||||
}
|
||||
|
||||
function normalizeNpcState(value: unknown): RuntimeNpcState {
|
||||
const rawState = isObject(value) ? value : {};
|
||||
|
||||
return {
|
||||
affinity: Math.round(readNumber(rawState.affinity, 0)),
|
||||
chattedCount: Math.max(0, Math.round(readNumber(rawState.chattedCount, 0))),
|
||||
helpUsed: readBoolean(rawState.helpUsed),
|
||||
giftsGiven: Math.max(0, Math.round(readNumber(rawState.giftsGiven, 0))),
|
||||
inventory: cloneJson(readArray(rawState.inventory)),
|
||||
recruited: readBoolean(rawState.recruited),
|
||||
firstMeaningfulContactResolved: readBoolean(
|
||||
rawState.firstMeaningfulContactResolved,
|
||||
),
|
||||
tradeStockSignature: readString(rawState.tradeStockSignature) || null,
|
||||
relationState: isObject(rawState.relationState)
|
||||
? cloneJson(rawState.relationState)
|
||||
: null,
|
||||
stanceProfile: isObject(rawState.stanceProfile)
|
||||
? cloneJson(rawState.stanceProfile)
|
||||
: null,
|
||||
revealedFacts: readArray(rawState.revealedFacts).filter(
|
||||
(item): item is string => typeof item === 'string' && item.trim().length > 0,
|
||||
),
|
||||
knownAttributeRumors: readArray(rawState.knownAttributeRumors).filter(
|
||||
(item): item is string => typeof item === 'string' && item.trim().length > 0,
|
||||
),
|
||||
seenBackstoryChapterIds: readArray(rawState.seenBackstoryChapterIds).filter(
|
||||
(item): item is string => typeof item === 'string' && item.trim().length > 0,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEncounter(value: unknown): RuntimeEncounter | null {
|
||||
const rawEncounter = isObject(value) ? value : null;
|
||||
if (!rawEncounter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kind = rawEncounter.kind === 'treasure' ? 'treasure' : 'npc';
|
||||
const npcName = readString(rawEncounter.npcName);
|
||||
if (!npcName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: readString(rawEncounter.id, npcName),
|
||||
kind,
|
||||
npcName,
|
||||
npcDescription: readString(rawEncounter.npcDescription),
|
||||
context: readString(rawEncounter.context),
|
||||
hostile:
|
||||
readBoolean(rawEncounter.hostile) ||
|
||||
Boolean(readString(rawEncounter.monsterPresetId)),
|
||||
characterId: readString(rawEncounter.characterId) || null,
|
||||
monsterPresetId: readString(rawEncounter.monsterPresetId) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHostileNpc(value: unknown): RuntimeHostileNpc | null {
|
||||
const rawNpc = isObject(value) ? value : null;
|
||||
if (!rawNpc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = readString(rawNpc.id);
|
||||
const name = readString(rawNpc.name, id);
|
||||
if (!id || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxHp = Math.max(1, Math.round(readNumber(rawNpc.maxHp, 1)));
|
||||
const hp = Math.max(0, Math.min(maxHp, Math.round(readNumber(rawNpc.hp, maxHp))));
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
hp,
|
||||
maxHp,
|
||||
description: readString(rawNpc.description),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCompanion(value: unknown): RuntimeCompanion | null {
|
||||
const rawCompanion = isObject(value) ? value : null;
|
||||
if (!rawCompanion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const npcId = readString(rawCompanion.npcId);
|
||||
if (!npcId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
npcId,
|
||||
characterId: readString(rawCompanion.characterId),
|
||||
joinedAtAffinity: Math.round(readNumber(rawCompanion.joinedAtAffinity, 0)),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNpcStates(value: unknown) {
|
||||
const rawStates = isObject(value) ? value : {};
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(rawStates).map(([key, state]) => [key, normalizeNpcState(state)]),
|
||||
) as Record<string, RuntimeNpcState>;
|
||||
}
|
||||
|
||||
function normalizeCompanions(value: unknown) {
|
||||
return readArray(value)
|
||||
.map((entry) => normalizeCompanion(entry))
|
||||
.filter((entry): entry is RuntimeCompanion => Boolean(entry));
|
||||
}
|
||||
|
||||
function normalizeHostileNpcs(value: unknown) {
|
||||
return readArray(value)
|
||||
.map((entry) => normalizeHostileNpc(entry))
|
||||
.filter((entry): entry is RuntimeHostileNpc => Boolean(entry));
|
||||
}
|
||||
|
||||
export function getEncounterKey(encounter: RuntimeEncounter) {
|
||||
return encounter.id || encounter.npcName;
|
||||
}
|
||||
|
||||
export function loadRuntimeSession(
|
||||
snapshot: SavedSnapshot,
|
||||
requestedSessionId: string,
|
||||
): RuntimeSession {
|
||||
const rawGameState = isObject(snapshot.gameState)
|
||||
? cloneJson(snapshot.gameState)
|
||||
: {};
|
||||
const currentEncounter = normalizeEncounter(rawGameState.currentEncounter);
|
||||
const sceneHostileNpcs = normalizeHostileNpcs(rawGameState.sceneHostileNpcs);
|
||||
const inBattle =
|
||||
readBoolean(rawGameState.inBattle) &&
|
||||
sceneHostileNpcs.some((npc) => npc.hp > 0);
|
||||
|
||||
return {
|
||||
sessionId: readString(rawGameState.runtimeSessionId, requestedSessionId),
|
||||
runtimeVersion: Math.max(
|
||||
0,
|
||||
Math.round(readNumber(rawGameState.runtimeActionVersion, 0)),
|
||||
),
|
||||
snapshotBottomTab: readString(snapshot.bottomTab, 'adventure'),
|
||||
rawGameState,
|
||||
worldType: readString(rawGameState.worldType) || null,
|
||||
storyHistory: normalizeStoryHistory(rawGameState.storyHistory),
|
||||
currentEncounter,
|
||||
npcInteractionActive: readBoolean(rawGameState.npcInteractionActive),
|
||||
sceneHostileNpcs,
|
||||
inBattle,
|
||||
playerHp: Math.max(0, Math.round(readNumber(rawGameState.playerHp, 0))),
|
||||
playerMaxHp: Math.max(1, Math.round(readNumber(rawGameState.playerMaxHp, 1))),
|
||||
playerMana: Math.max(0, Math.round(readNumber(rawGameState.playerMana, 0))),
|
||||
playerMaxMana: Math.max(
|
||||
1,
|
||||
Math.round(readNumber(rawGameState.playerMaxMana, 1)),
|
||||
),
|
||||
npcStates: normalizeNpcStates(rawGameState.npcStates),
|
||||
companions: normalizeCompanions(rawGameState.companions),
|
||||
currentNpcBattleMode:
|
||||
rawGameState.currentNpcBattleMode === 'fight' ||
|
||||
rawGameState.currentNpcBattleMode === 'spar'
|
||||
? rawGameState.currentNpcBattleMode
|
||||
: null,
|
||||
currentNpcBattleOutcome:
|
||||
rawGameState.currentNpcBattleOutcome === 'fight_victory' ||
|
||||
rawGameState.currentNpcBattleOutcome === 'spar_complete'
|
||||
? rawGameState.currentNpcBattleOutcome
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function isStoryFunctionId(functionId: string) {
|
||||
return STORY_FUNCTION_IDS.has(functionId);
|
||||
}
|
||||
|
||||
export function isCombatFunctionId(functionId: string) {
|
||||
return COMBAT_FUNCTION_IDS.has(functionId);
|
||||
}
|
||||
|
||||
export function isNpcFunctionId(functionId: string) {
|
||||
return NPC_FUNCTION_IDS.has(functionId);
|
||||
}
|
||||
|
||||
export function isTask5FunctionId(functionId: string) {
|
||||
return (
|
||||
isStoryFunctionId(functionId) ||
|
||||
isCombatFunctionId(functionId) ||
|
||||
isNpcFunctionId(functionId)
|
||||
);
|
||||
}
|
||||
|
||||
export function isTask6RuntimeFunctionId(functionId: string) {
|
||||
return TASK6_RUNTIME_FUNCTION_ID_SET.has(functionId);
|
||||
}
|
||||
|
||||
export function getEncounterNpcState(session: RuntimeSession) {
|
||||
if (!session.currentEncounter || session.currentEncounter.kind !== 'npc') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = getEncounterKey(session.currentEncounter);
|
||||
return (
|
||||
session.npcStates[key] ?? {
|
||||
affinity: 0,
|
||||
chattedCount: 0,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
firstMeaningfulContactResolved: false,
|
||||
relationState: null,
|
||||
stanceProfile: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function setEncounterNpcState(
|
||||
session: RuntimeSession,
|
||||
npcState: RuntimeNpcState,
|
||||
) {
|
||||
if (!session.currentEncounter || session.currentEncounter.kind !== 'npc') {
|
||||
return;
|
||||
}
|
||||
|
||||
session.npcStates[getEncounterKey(session.currentEncounter)] = npcState;
|
||||
}
|
||||
|
||||
function buildOptionView(
|
||||
functionId: string,
|
||||
overrides: Partial<RuntimeStoryOptionView> = {},
|
||||
): RuntimeStoryOptionView {
|
||||
const definition = FUNCTION_DEFINITIONS[functionId];
|
||||
if (!definition) {
|
||||
return {
|
||||
functionId,
|
||||
actionText: functionId,
|
||||
detailText: '',
|
||||
scope: 'story',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
functionId,
|
||||
actionText: definition.actionText,
|
||||
detailText: definition.detailText,
|
||||
scope: definition.scope,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type RuntimeQuestPreview = {
|
||||
id: string;
|
||||
issuerNpcId: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
function readQuestPreviews(session: RuntimeSession): RuntimeQuestPreview[] {
|
||||
return readArray(session.rawGameState.quests)
|
||||
.map((quest) => {
|
||||
const rawQuest = isObject(quest) ? quest : {};
|
||||
const id = readString(rawQuest.id);
|
||||
const issuerNpcId = readString(rawQuest.issuerNpcId);
|
||||
const status = readString(rawQuest.status);
|
||||
|
||||
if (!id || !issuerNpcId || !status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
issuerNpcId,
|
||||
status,
|
||||
} satisfies RuntimeQuestPreview;
|
||||
})
|
||||
.filter((quest): quest is RuntimeQuestPreview => Boolean(quest));
|
||||
}
|
||||
|
||||
function getActiveEncounterQuest(session: RuntimeSession) {
|
||||
if (!session.currentEncounter || session.currentEncounter.kind !== 'npc') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
readQuestPreviews(session).find(
|
||||
(quest) =>
|
||||
quest.issuerNpcId === session.currentEncounter?.id &&
|
||||
quest.status !== 'turned_in',
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function hasGiftablePlayerInventory(session: RuntimeSession) {
|
||||
return readArray(session.rawGameState.playerInventory).some((item) => {
|
||||
const rawItem = isObject(item) ? item : {};
|
||||
return readNumber(rawItem.quantity, 0) > 0;
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
if (session.currentEncounter?.kind === 'npc') {
|
||||
const npcState = getEncounterNpcState(session);
|
||||
if (session.currentEncounter.hostile) {
|
||||
return [
|
||||
buildOptionView('npc_fight'),
|
||||
buildOptionView('npc_leave'),
|
||||
];
|
||||
}
|
||||
|
||||
if (!session.npcInteractionActive) {
|
||||
return [
|
||||
buildOptionView('npc_preview_talk'),
|
||||
buildOptionView('npc_fight'),
|
||||
buildOptionView('npc_leave'),
|
||||
];
|
||||
}
|
||||
|
||||
const activeQuest = getActiveEncounterQuest(session);
|
||||
const options = [
|
||||
buildOptionView('npc_chat'),
|
||||
buildOptionView('npc_help', npcState?.helpUsed
|
||||
? {
|
||||
disabled: true,
|
||||
reason: '当前 NPC 的一次性援手已经用完了。',
|
||||
}
|
||||
: {}),
|
||||
buildOptionView('npc_spar'),
|
||||
buildOptionView('npc_fight'),
|
||||
];
|
||||
|
||||
if ((npcState?.inventory?.length ?? 0) > 0) {
|
||||
options.push(buildOptionView('npc_trade'));
|
||||
}
|
||||
|
||||
if (hasGiftablePlayerInventory(session)) {
|
||||
options.push(buildOptionView('npc_gift'));
|
||||
}
|
||||
|
||||
if (
|
||||
activeQuest &&
|
||||
(activeQuest.status === 'completed' ||
|
||||
activeQuest.status === 'ready_to_turn_in')
|
||||
) {
|
||||
options.push(buildOptionView('npc_quest_turn_in'));
|
||||
} else if (!activeQuest) {
|
||||
options.push(buildOptionView('npc_quest_accept'));
|
||||
}
|
||||
|
||||
if (npcState && !npcState.recruited && npcState.affinity >= 60) {
|
||||
options.push(
|
||||
buildOptionView(
|
||||
'npc_recruit',
|
||||
session.companions.length >= MAX_TASK5_COMPANIONS
|
||||
? {
|
||||
disabled: true,
|
||||
reason: '队伍已满,任务5首轮后端接口暂不处理换队逻辑。',
|
||||
}
|
||||
: {},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
options.push(buildOptionView('npc_leave'));
|
||||
return options;
|
||||
}
|
||||
|
||||
if (session.currentEncounter?.kind === 'treasure') {
|
||||
return [
|
||||
buildOptionView('treasure_secure'),
|
||||
buildOptionView('treasure_inspect'),
|
||||
buildOptionView('treasure_leave'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'idle_observe_signs',
|
||||
'idle_call_out',
|
||||
'idle_rest_focus',
|
||||
'idle_explore_forward',
|
||||
'idle_travel_next_scene',
|
||||
'story_continue_adventure',
|
||||
].map((functionId) => buildOptionView(functionId));
|
||||
}
|
||||
|
||||
function buildEncounterViewModel(
|
||||
session: RuntimeSession,
|
||||
): RuntimeStoryEncounterViewModel | null {
|
||||
if (!session.currentEncounter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const npcState = getEncounterNpcState(session);
|
||||
return {
|
||||
id: session.currentEncounter.id,
|
||||
kind: session.currentEncounter.kind,
|
||||
npcName: session.currentEncounter.npcName,
|
||||
hostile: session.currentEncounter.hostile,
|
||||
affinity: npcState?.affinity,
|
||||
recruited: npcState?.recruited,
|
||||
interactionActive: session.npcInteractionActive,
|
||||
battleMode: session.currentNpcBattleMode,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRuntimeViewModel(
|
||||
session: RuntimeSession,
|
||||
options = buildAvailableOptions(session),
|
||||
): RuntimeStoryViewModel {
|
||||
return {
|
||||
player: {
|
||||
hp: session.playerHp,
|
||||
maxHp: session.playerMaxHp,
|
||||
mana: session.playerMana,
|
||||
maxMana: session.playerMaxMana,
|
||||
},
|
||||
encounter: buildEncounterViewModel(session),
|
||||
companions: session.companions.map((companion) => ({
|
||||
npcId: companion.npcId,
|
||||
characterId: companion.characterId || undefined,
|
||||
joinedAtAffinity: companion.joinedAtAffinity,
|
||||
})),
|
||||
availableOptions: options,
|
||||
status: {
|
||||
inBattle: session.inBattle,
|
||||
npcInteractionActive: session.npcInteractionActive,
|
||||
currentNpcBattleMode: session.currentNpcBattleMode,
|
||||
currentNpcBattleOutcome: session.currentNpcBattleOutcome,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function appendStoryHistory(
|
||||
session: RuntimeSession,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
) {
|
||||
session.storyHistory.push(
|
||||
{
|
||||
text: actionText,
|
||||
historyRole: 'action',
|
||||
},
|
||||
{
|
||||
text: resultText,
|
||||
historyRole: 'result',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function buildLegacyCurrentStory(
|
||||
storyText: string,
|
||||
options: RuntimeStoryOptionView[],
|
||||
) {
|
||||
return {
|
||||
text: storyText,
|
||||
options: options.map((option) => ({
|
||||
functionId: option.functionId,
|
||||
actionText: option.actionText,
|
||||
text: option.actionText,
|
||||
detailText: option.detailText,
|
||||
priority: option.scope === 'npc' ? 3 : option.scope === 'combat' ? 2 : 1,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function syncRawGameState(session: RuntimeSession) {
|
||||
session.rawGameState.runtimeSessionId = session.sessionId;
|
||||
session.rawGameState.runtimeActionVersion = session.runtimeVersion;
|
||||
session.rawGameState.storyHistory = cloneJson(session.storyHistory);
|
||||
session.rawGameState.currentEncounter = session.currentEncounter
|
||||
? cloneJson(session.currentEncounter)
|
||||
: null;
|
||||
session.rawGameState.npcInteractionActive = session.npcInteractionActive;
|
||||
session.rawGameState.sceneHostileNpcs = cloneJson(session.sceneHostileNpcs);
|
||||
session.rawGameState.inBattle = session.inBattle;
|
||||
session.rawGameState.playerHp = session.playerHp;
|
||||
session.rawGameState.playerMaxHp = session.playerMaxHp;
|
||||
session.rawGameState.playerMana = session.playerMana;
|
||||
session.rawGameState.playerMaxMana = session.playerMaxMana;
|
||||
session.rawGameState.npcStates = cloneJson(session.npcStates);
|
||||
session.rawGameState.companions = cloneJson(session.companions);
|
||||
session.rawGameState.currentNpcBattleMode = session.currentNpcBattleMode;
|
||||
session.rawGameState.currentNpcBattleOutcome = session.currentNpcBattleOutcome;
|
||||
session.rawGameState.currentBattleNpcId = session.currentEncounter?.id ?? null;
|
||||
session.rawGameState.activeCombatEffects = [];
|
||||
session.rawGameState.playerActionMode = 'idle';
|
||||
session.rawGameState.scrollWorld = false;
|
||||
session.rawGameState.animationState = 'idle';
|
||||
}
|
||||
|
||||
export function replaceRuntimeSessionRawGameState(
|
||||
session: RuntimeSession,
|
||||
nextGameState: JsonRecord,
|
||||
) {
|
||||
session.rawGameState = cloneJson(nextGameState);
|
||||
const refreshed = loadRuntimeSession(
|
||||
{
|
||||
version: 2,
|
||||
savedAt: '',
|
||||
bottomTab: session.snapshotBottomTab,
|
||||
gameState: session.rawGameState,
|
||||
currentStory: null,
|
||||
},
|
||||
session.sessionId,
|
||||
);
|
||||
|
||||
session.worldType = refreshed.worldType;
|
||||
session.storyHistory = refreshed.storyHistory;
|
||||
session.currentEncounter = refreshed.currentEncounter;
|
||||
session.npcInteractionActive = refreshed.npcInteractionActive;
|
||||
session.sceneHostileNpcs = refreshed.sceneHostileNpcs;
|
||||
session.inBattle = refreshed.inBattle;
|
||||
session.playerHp = refreshed.playerHp;
|
||||
session.playerMaxHp = refreshed.playerMaxHp;
|
||||
session.playerMana = refreshed.playerMana;
|
||||
session.playerMaxMana = refreshed.playerMaxMana;
|
||||
session.npcStates = refreshed.npcStates;
|
||||
session.companions = refreshed.companions;
|
||||
session.currentNpcBattleMode = refreshed.currentNpcBattleMode;
|
||||
session.currentNpcBattleOutcome = refreshed.currentNpcBattleOutcome;
|
||||
}
|
||||
1123
server-node/src/modules/story/storyActionRoutes.test.ts
Normal file
1123
server-node/src/modules/story/storyActionRoutes.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
73
server-node/src/modules/story/storyActionRoutes.ts
Normal file
73
server-node/src/modules/story/storyActionRoutes.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { RuntimeStoryActionRequest } from '../../../../packages/shared/src/contracts/story.js';
|
||||
import type { AppContext } from '../../context.js';
|
||||
import { badRequest } from '../../errors.js';
|
||||
import { asyncHandler, sendApiResponse } from '../../http.js';
|
||||
import { requireJwtAuth } from '../../middleware/auth.js';
|
||||
import { routeMeta } from '../../middleware/routeMeta.js';
|
||||
import {
|
||||
getRuntimeStoryState,
|
||||
resolveRuntimeStoryAction,
|
||||
} from './storyActionService.js';
|
||||
|
||||
const actionPayloadSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
const runtimeStoryActionSchema = z.object({
|
||||
sessionId: z.string().trim().min(1),
|
||||
clientVersion: z.number().int().min(0).optional(),
|
||||
action: z.object({
|
||||
type: z.literal('story_choice'),
|
||||
functionId: z.string().trim().min(1),
|
||||
targetId: z.string().trim().optional(),
|
||||
payload: actionPayloadSchema.optional().default({}),
|
||||
}),
|
||||
});
|
||||
|
||||
export function createStoryActionRoutes(context: AppContext) {
|
||||
const router = Router();
|
||||
const requireAuth = requireJwtAuth(context.config, context.userRepository);
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
router.post(
|
||||
'/actions/resolve',
|
||||
routeMeta({ operation: 'runtime.story.actions.resolve' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = runtimeStoryActionSchema.parse(
|
||||
request.body,
|
||||
) as RuntimeStoryActionRequest;
|
||||
sendApiResponse(
|
||||
response,
|
||||
await resolveRuntimeStoryAction({
|
||||
runtimeRepository: context.runtimeRepository,
|
||||
userId: request.userId!,
|
||||
request: payload,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/state/:sessionId',
|
||||
routeMeta({ operation: 'runtime.story.state.get' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const sessionId = request.params.sessionId?.trim() || '';
|
||||
if (!sessionId) {
|
||||
throw badRequest('sessionId is required');
|
||||
}
|
||||
|
||||
sendApiResponse(
|
||||
response,
|
||||
await getRuntimeStoryState({
|
||||
runtimeRepository: context.runtimeRepository,
|
||||
userId: request.userId!,
|
||||
sessionId,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
377
server-node/src/modules/story/storyActionService.ts
Normal file
377
server-node/src/modules/story/storyActionService.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import type {
|
||||
RuntimeBattlePresentation,
|
||||
RuntimeStoryActionRequest,
|
||||
RuntimeStoryActionResponse,
|
||||
RuntimeStoryPatch,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import { conflict, invalidRequest } from '../../errors.js';
|
||||
import type { RuntimeRepositoryPort } from '../../repositories/runtimeRepository.js';
|
||||
import { resolveCombatAction } from '../combat/combatResolutionService.js';
|
||||
import { resolveInventoryStoryAction, isSupportedInventoryStoryFunctionId } from '../inventory/inventoryStoryActionService.js';
|
||||
import {
|
||||
ensureNpcInventorySessionState,
|
||||
isSupportedNpcInventoryStoryFunctionId,
|
||||
resolveNpcInventoryStoryAction,
|
||||
} from '../inventory/npcInventoryStoryActionService.js';
|
||||
import { resolveNpcInteraction } from '../npc/npcInteractionService.js';
|
||||
import {
|
||||
applyQuestSignalsForResolvedAction,
|
||||
} from '../quest/questRuntimeSignalService.js';
|
||||
import {
|
||||
isSupportedQuestStoryFunctionId,
|
||||
resolveQuestStoryAction,
|
||||
} from '../quest/questStoryActionService.js';
|
||||
import {
|
||||
isSupportedTreasureStoryFunctionId,
|
||||
resolveTreasureStoryAction,
|
||||
} from '../runtime-item/treasureStoryActionService.js';
|
||||
import {
|
||||
TASK6_DEFERRED_FUNCTION_IDS,
|
||||
appendStoryHistory,
|
||||
buildAvailableOptions,
|
||||
buildLegacyCurrentStory,
|
||||
buildRuntimeViewModel,
|
||||
getEncounterNpcState,
|
||||
isCombatFunctionId,
|
||||
isNpcFunctionId,
|
||||
isStoryFunctionId,
|
||||
isTask5FunctionId,
|
||||
loadRuntimeSession,
|
||||
setEncounterNpcState,
|
||||
syncRawGameState,
|
||||
type RuntimeSession,
|
||||
} from './runtimeSession.js';
|
||||
import {
|
||||
hydrateSavedSnapshot,
|
||||
normalizeSavedSnapshotPayload,
|
||||
} from '../runtime/runtimeSnapshotHydration.js';
|
||||
|
||||
type StoryResolution = {
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
patches: RuntimeStoryPatch[];
|
||||
storyText?: string;
|
||||
battle?: RuntimeBattlePresentation | null;
|
||||
toast?: string | null;
|
||||
};
|
||||
|
||||
function resolveActionText(defaultText: string, request: RuntimeStoryActionRequest) {
|
||||
const payload = request.action.payload;
|
||||
const optionText =
|
||||
payload && typeof payload.optionText === 'string'
|
||||
? payload.optionText.trim()
|
||||
: '';
|
||||
|
||||
return optionText || defaultText;
|
||||
}
|
||||
|
||||
function normalizeStatusPatch(session: RuntimeSession) {
|
||||
return {
|
||||
type: 'status_changed',
|
||||
inBattle: session.inBattle,
|
||||
npcInteractionActive: session.npcInteractionActive,
|
||||
currentNpcBattleMode: session.currentNpcBattleMode,
|
||||
currentNpcBattleOutcome: session.currentNpcBattleOutcome,
|
||||
} satisfies RuntimeStoryPatch;
|
||||
}
|
||||
|
||||
function clearEncounterState(session: RuntimeSession) {
|
||||
session.currentEncounter = null;
|
||||
session.npcInteractionActive = false;
|
||||
session.sceneHostileNpcs = [];
|
||||
session.inBattle = false;
|
||||
session.currentNpcBattleMode = null;
|
||||
}
|
||||
|
||||
function readSavedStoryText(currentStory: unknown) {
|
||||
if (
|
||||
currentStory &&
|
||||
typeof currentStory === 'object' &&
|
||||
'text' in currentStory &&
|
||||
typeof currentStory.text === 'string' &&
|
||||
currentStory.text.trim()
|
||||
) {
|
||||
return currentStory.text.trim();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildFallbackStoryText(session: RuntimeSession) {
|
||||
if (session.inBattle && session.sceneHostileNpcs.length > 0) {
|
||||
return `眼前的冲突还没结束,${session.sceneHostileNpcs[0]!.name}仍在逼你立刻做出下一步判断。`;
|
||||
}
|
||||
|
||||
if (session.currentEncounter?.kind === 'npc') {
|
||||
return `${session.currentEncounter.npcName}正在等你表态,接下来这一轮该怎么回应,由服务端规则来继续收口。`;
|
||||
}
|
||||
|
||||
return '当前故事状态已经同步到后端,接下来可以继续推进这一轮运行时动作。';
|
||||
}
|
||||
|
||||
function resolveStoryFlowAction(
|
||||
session: RuntimeSession,
|
||||
functionId: string,
|
||||
): StoryResolution {
|
||||
switch (functionId) {
|
||||
case 'story_continue_adventure':
|
||||
return {
|
||||
actionText: '继续推进冒险',
|
||||
resultText: '你没有把节奏停下来,而是顺着当前局势继续向前推进了这一段故事。',
|
||||
patches: [normalizeStatusPatch(session)],
|
||||
};
|
||||
case 'story_opening_camp_dialogue': {
|
||||
const encounter = session.currentEncounter;
|
||||
const npcState = getEncounterNpcState(session);
|
||||
if (encounter && npcState) {
|
||||
const nextAffinity = npcState.affinity + 2;
|
||||
setEncounterNpcState(session, {
|
||||
...npcState,
|
||||
affinity: nextAffinity,
|
||||
firstMeaningfulContactResolved: true,
|
||||
});
|
||||
session.npcInteractionActive = true;
|
||||
|
||||
return {
|
||||
actionText: `与${encounter.npcName}交换开场判断`,
|
||||
resultText: `${encounter.npcName}终于愿意把营地里的第一轮判断说出口,彼此的警惕也略微放下了一点。`,
|
||||
patches: [
|
||||
{
|
||||
type: 'npc_affinity_changed',
|
||||
npcId: encounter.id,
|
||||
previousAffinity: npcState.affinity,
|
||||
nextAffinity,
|
||||
},
|
||||
normalizeStatusPatch(session),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
actionText: '交换开场判断',
|
||||
resultText: '你先把眼前局势梳理了一遍,为后续真正推进对话和行动留出了空间。',
|
||||
patches: [normalizeStatusPatch(session)],
|
||||
};
|
||||
}
|
||||
case 'camp_travel_home_scene':
|
||||
clearEncounterState(session);
|
||||
return {
|
||||
actionText: '返回营地',
|
||||
resultText: '你主动结束了当前遭遇,把这轮流程带回了更安全的营地节奏里。',
|
||||
patches: [
|
||||
normalizeStatusPatch(session),
|
||||
{
|
||||
type: 'encounter_changed',
|
||||
encounterId: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
case 'idle_call_out':
|
||||
return {
|
||||
actionText: '主动出声试探',
|
||||
resultText: '你的喊话打破了当前的静场,周围潜着的动静也因此更难继续藏着。',
|
||||
patches: [normalizeStatusPatch(session)],
|
||||
};
|
||||
case 'idle_explore_forward':
|
||||
return {
|
||||
actionText: '继续向前探索',
|
||||
resultText: '你没有停在原地,而是继续往前压,把下一段遭遇主动推到了自己面前。',
|
||||
patches: [normalizeStatusPatch(session)],
|
||||
};
|
||||
case 'idle_observe_signs':
|
||||
return {
|
||||
actionText: '观察周围迹象',
|
||||
resultText: '你先压住动作,把风向、脚印和气味这些细节重新读了一遍。',
|
||||
patches: [normalizeStatusPatch(session)],
|
||||
};
|
||||
case 'idle_rest_focus':
|
||||
session.playerHp = Math.min(session.playerMaxHp, session.playerHp + 8);
|
||||
session.playerMana = Math.min(session.playerMaxMana, session.playerMana + 6);
|
||||
return {
|
||||
actionText: '原地调息',
|
||||
resultText: '你把呼吸慢下来重新稳住节奏,生命和灵力都回上来一点。',
|
||||
patches: [normalizeStatusPatch(session)],
|
||||
};
|
||||
case 'idle_travel_next_scene':
|
||||
clearEncounterState(session);
|
||||
return {
|
||||
actionText: '前往相邻场景',
|
||||
resultText: '你收束了这一段遭遇,顺着路线把故事推进到新的场景段落。',
|
||||
patches: [
|
||||
normalizeStatusPatch(session),
|
||||
{
|
||||
type: 'encounter_changed',
|
||||
encounterId: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
default:
|
||||
throw invalidRequest(`暂不支持的 story action:${functionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveRuntimeStoryAction(params: {
|
||||
runtimeRepository: RuntimeRepositoryPort;
|
||||
userId: string;
|
||||
request: RuntimeStoryActionRequest;
|
||||
}) {
|
||||
const snapshot = await params.runtimeRepository.getSnapshot(params.userId);
|
||||
if (!snapshot) {
|
||||
throw conflict('运行时快照不存在,请先初始化并保存一次游戏');
|
||||
}
|
||||
const hydratedSnapshot = hydrateSavedSnapshot(snapshot)!;
|
||||
|
||||
const functionId =
|
||||
typeof params.request.action.functionId === 'string'
|
||||
? params.request.action.functionId.trim()
|
||||
: '';
|
||||
if (!functionId) {
|
||||
throw invalidRequest('functionId 不能为空');
|
||||
}
|
||||
|
||||
if (
|
||||
!isSupportedInventoryStoryFunctionId(functionId) &&
|
||||
!isSupportedNpcInventoryStoryFunctionId(functionId) &&
|
||||
!isSupportedQuestStoryFunctionId(functionId) &&
|
||||
!isSupportedTreasureStoryFunctionId(functionId) &&
|
||||
TASK6_DEFERRED_FUNCTION_IDS.has(functionId)
|
||||
) {
|
||||
throw conflict(
|
||||
`动作 ${functionId} 属于任务6的 Inventory / Quest / Build 范围,本轮任务5接口暂未承接`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!isSupportedInventoryStoryFunctionId(functionId) &&
|
||||
!isSupportedNpcInventoryStoryFunctionId(functionId) &&
|
||||
!isSupportedQuestStoryFunctionId(functionId) &&
|
||||
!isSupportedTreasureStoryFunctionId(functionId) &&
|
||||
!isTask5FunctionId(functionId)
|
||||
) {
|
||||
throw invalidRequest(`暂不支持的 runtime action:${functionId}`);
|
||||
}
|
||||
|
||||
const session = loadRuntimeSession(
|
||||
hydratedSnapshot,
|
||||
params.request.sessionId,
|
||||
);
|
||||
if (
|
||||
typeof params.request.clientVersion === 'number' &&
|
||||
params.request.clientVersion !== session.runtimeVersion
|
||||
) {
|
||||
throw conflict('运行时版本已变化,请先同步最新快照后再提交动作', {
|
||||
clientVersion: params.request.clientVersion,
|
||||
serverVersion: session.runtimeVersion,
|
||||
});
|
||||
}
|
||||
|
||||
let resolution: StoryResolution;
|
||||
const previousEncounter = session.currentEncounter
|
||||
? { ...session.currentEncounter }
|
||||
: null;
|
||||
if (isCombatFunctionId(functionId)) {
|
||||
resolution = resolveCombatAction(session, functionId);
|
||||
} else if (isNpcFunctionId(functionId)) {
|
||||
resolution = resolveNpcInteraction(session, functionId);
|
||||
} else if (isSupportedInventoryStoryFunctionId(functionId)) {
|
||||
resolution = resolveInventoryStoryAction(session, params.request);
|
||||
} else if (isSupportedNpcInventoryStoryFunctionId(functionId)) {
|
||||
resolution = resolveNpcInventoryStoryAction(session, params.request);
|
||||
} else if (isSupportedQuestStoryFunctionId(functionId)) {
|
||||
resolution = resolveQuestStoryAction(session, params.request);
|
||||
} else if (isSupportedTreasureStoryFunctionId(functionId)) {
|
||||
resolution = resolveTreasureStoryAction(session, params.request);
|
||||
} else if (isStoryFunctionId(functionId)) {
|
||||
resolution = resolveStoryFlowAction(session, functionId);
|
||||
} else {
|
||||
throw invalidRequest(`当前动作没有可用的后端执行器:${functionId}`);
|
||||
}
|
||||
|
||||
syncRawGameState(session);
|
||||
applyQuestSignalsForResolvedAction({
|
||||
session,
|
||||
functionId,
|
||||
previousEncounter,
|
||||
battle: resolution.battle ?? null,
|
||||
});
|
||||
|
||||
const actionText = resolveActionText(resolution.actionText, params.request);
|
||||
const storyText = resolution.storyText ?? resolution.resultText;
|
||||
|
||||
appendStoryHistory(session, actionText, resolution.resultText);
|
||||
session.runtimeVersion += 1;
|
||||
session.sessionId = params.request.sessionId;
|
||||
|
||||
syncRawGameState(session);
|
||||
ensureNpcInventorySessionState(session);
|
||||
const options = buildAvailableOptions(session);
|
||||
syncRawGameState(session);
|
||||
|
||||
const persistedSnapshot = await params.runtimeRepository.putSnapshot(
|
||||
params.userId,
|
||||
normalizeSavedSnapshotPayload({
|
||||
savedAt: new Date().toISOString(),
|
||||
bottomTab: session.snapshotBottomTab,
|
||||
gameState: session.rawGameState,
|
||||
currentStory: buildLegacyCurrentStory(storyText, options),
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
serverVersion: session.runtimeVersion,
|
||||
viewModel: buildRuntimeViewModel(session, options),
|
||||
presentation: {
|
||||
actionText,
|
||||
resultText: resolution.resultText,
|
||||
storyText,
|
||||
options,
|
||||
toast: resolution.toast ?? null,
|
||||
battle: resolution.battle ?? null,
|
||||
},
|
||||
patches: [
|
||||
{
|
||||
type: 'story_history_append',
|
||||
actionText,
|
||||
resultText: resolution.resultText,
|
||||
},
|
||||
...resolution.patches,
|
||||
],
|
||||
snapshot: hydrateSavedSnapshot(persistedSnapshot)!,
|
||||
} satisfies RuntimeStoryActionResponse;
|
||||
}
|
||||
|
||||
export async function getRuntimeStoryState(params: {
|
||||
runtimeRepository: RuntimeRepositoryPort;
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
}) {
|
||||
const snapshot = await params.runtimeRepository.getSnapshot(params.userId);
|
||||
if (!snapshot) {
|
||||
throw conflict('运行时快照不存在,请先初始化并保存一次游戏');
|
||||
}
|
||||
const hydratedSnapshot = hydrateSavedSnapshot(snapshot)!;
|
||||
|
||||
const session = loadRuntimeSession(hydratedSnapshot, params.sessionId);
|
||||
ensureNpcInventorySessionState(session);
|
||||
const options = buildAvailableOptions(session);
|
||||
const storyText =
|
||||
readSavedStoryText(hydratedSnapshot.currentStory) ||
|
||||
buildFallbackStoryText(session);
|
||||
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
serverVersion: session.runtimeVersion,
|
||||
viewModel: buildRuntimeViewModel(session, options),
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText,
|
||||
options,
|
||||
toast: null,
|
||||
battle: null,
|
||||
},
|
||||
patches: [],
|
||||
snapshot: hydratedSnapshot,
|
||||
} satisfies RuntimeStoryActionResponse;
|
||||
}
|
||||
Reference in New Issue
Block a user