334 lines
12 KiB
TypeScript
334 lines
12 KiB
TypeScript
import {
|
||
buildSchemaSummary,
|
||
describeTopAttributes,
|
||
formatAttributeList,
|
||
resolveAttributeSchema,
|
||
resolveCharacterAttributeProfile,
|
||
} from '../data/attributeResolver';
|
||
import {
|
||
buildCharacterBackstoryPromptContext,
|
||
getCharacterPublicBackstorySummary,
|
||
getLockedCharacterBackstoryChapters,
|
||
} from '../data/characterPresets';
|
||
import {
|
||
AnimationState,
|
||
Character,
|
||
CharacterChatTurn,
|
||
CustomWorldProfile,
|
||
FacingDirection,
|
||
StoryMoment,
|
||
WorldType,
|
||
} from '../types';
|
||
import { buildCustomWorldReferenceText } from '../services/customWorld';
|
||
import { buildStoryPromptHistory } from '../services/storyHistory';
|
||
|
||
export interface CharacterChatTargetStatus {
|
||
roleLabel?: string | null;
|
||
hp: number;
|
||
maxHp: number;
|
||
mana: number;
|
||
maxMana: number;
|
||
affinity?: number | null;
|
||
}
|
||
|
||
export interface CharacterChatPromptContext {
|
||
playerHp: number;
|
||
playerMaxHp: number;
|
||
playerMana: number;
|
||
playerMaxMana: number;
|
||
inBattle: boolean;
|
||
playerFacing: FacingDirection;
|
||
playerAnimation: AnimationState;
|
||
sceneName?: string | null;
|
||
sceneDescription?: string | null;
|
||
customWorldProfile?: CustomWorldProfile | null;
|
||
}
|
||
|
||
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 = `总结玩家与这名角色之间不断变化的关系。
|
||
只输出一段简洁文字。
|
||
包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`;
|
||
|
||
function describeWorld(world: WorldType) {
|
||
if (world === WorldType.WUXIA) return '边城模板';
|
||
if (world === WorldType.XIANXIA) return '灵潮模板';
|
||
return '自定义世界';
|
||
}
|
||
|
||
function describeCustomWorldSection(customWorldProfile?: CustomWorldProfile | null) {
|
||
return customWorldProfile
|
||
? `自定义世界参考:\n${buildCustomWorldReferenceText(customWorldProfile)}`
|
||
: null;
|
||
}
|
||
|
||
function describeGender(gender: Character['gender']) {
|
||
if (gender === 'female') return '女';
|
||
if (gender === 'male') return '男';
|
||
return '未知';
|
||
}
|
||
|
||
function describeFacing(facing: FacingDirection) {
|
||
return facing === 'left' ? '左' : '右';
|
||
}
|
||
|
||
function describeHpBand(ratio: number) {
|
||
if (ratio >= 0.95) return '几乎无伤';
|
||
if (ratio >= 0.75) return '状态稳健';
|
||
if (ratio >= 0.55) return '略有消耗';
|
||
if (ratio >= 0.35) return '伤势明显';
|
||
if (ratio >= 0.15) return '伤势沉重';
|
||
return '濒临极限';
|
||
}
|
||
|
||
function describeManaBand(ratio: number) {
|
||
if (ratio >= 0.9) return '充盈';
|
||
if (ratio >= 0.7) return '稳定';
|
||
if (ratio >= 0.45) return '尚可';
|
||
if (ratio >= 0.2) return '偏低';
|
||
if (ratio > 0) return '接近枯竭';
|
||
return '耗尽';
|
||
}
|
||
|
||
function describeStoryHistory(history: StoryMoment[]) {
|
||
const promptHistory = buildStoryPromptHistory(history);
|
||
|
||
if (!promptHistory.previousSummary && promptHistory.recentOriginalRounds.length === 0) {
|
||
return '近期剧情:暂无。';
|
||
}
|
||
|
||
return [
|
||
promptHistory.previousSummary
|
||
? `更早剧情摘要:\n${promptHistory.previousSummary}`
|
||
: '更早剧情摘要:暂无。',
|
||
promptHistory.recentOriginalRounds.length > 0
|
||
? `最近 3 轮剧情:\n${promptHistory.recentOriginalRounds
|
||
.map((item, index) => `- 第 ${index + 1} 轮:\n${item}`)
|
||
.join('\n')}`
|
||
: '最近 3 轮剧情:暂无。',
|
||
].join('\n');
|
||
}
|
||
|
||
function describeBackstoryContext(label: string, snippets: string[]) {
|
||
const normalized = snippets
|
||
.map(snippet => snippet.trim())
|
||
.filter(Boolean);
|
||
|
||
if (normalized.length === 0) {
|
||
return [`${label}:暂无公开信息。`];
|
||
}
|
||
|
||
return normalized.map((snippet, index) =>
|
||
`${label}${index === 0 ? '(公开层)' : `(已解锁片段 ${index})`}:${snippet}`,
|
||
);
|
||
}
|
||
|
||
function describeCharacterInfo(
|
||
label: string,
|
||
character: Character,
|
||
world: WorldType,
|
||
customWorldProfile?: CustomWorldProfile | null,
|
||
options: {
|
||
affinity?: number | null;
|
||
includeUnlockProgress?: boolean;
|
||
} = {},
|
||
) {
|
||
const schema = resolveAttributeSchema(world, customWorldProfile);
|
||
const attributeProfile = resolveCharacterAttributeProfile(character, world, customWorldProfile);
|
||
const skills = character.skills.length > 0
|
||
? character.skills
|
||
.map(
|
||
skill => `${skill.name}(伤害 ${skill.damage}/灵力 ${skill.manaCost}/冷却 ${skill.cooldownTurns})`,
|
||
)
|
||
.join(' | ')
|
||
: '无';
|
||
const backgroundLines = options.affinity == null
|
||
? [getCharacterPublicBackstorySummary(character, world)]
|
||
: buildCharacterBackstoryPromptContext(character, options.affinity, world);
|
||
const nextLockedChapter = options.includeUnlockProgress && options.affinity != null
|
||
? getLockedCharacterBackstoryChapters(character, options.affinity, world)[0] ?? null
|
||
: null;
|
||
const schemaSummary = buildSchemaSummary(schema)
|
||
.map(slot => `${slot.name}(${slot.definition})`)
|
||
.join(' | ');
|
||
const topAttributes = describeTopAttributes(attributeProfile, schema).join('、') || '无';
|
||
const attributeDetails = formatAttributeList(attributeProfile, schema)
|
||
.map(entry => `${entry.slot.name} ${entry.value}`)
|
||
.join(' | ');
|
||
|
||
return [
|
||
`${label}姓名:${character.name}`,
|
||
`${label}称号:${character.title}`,
|
||
`${label}性别:${describeGender(character.gender ?? 'unknown')}`,
|
||
`${label}描述:${character.description}`,
|
||
...describeBackstoryContext(`${label}背景`, backgroundLines),
|
||
nextLockedChapter
|
||
? `${label}未解锁背景:${nextLockedChapter.title}(需好感 ${nextLockedChapter.affinityRequired},当前只知道:${nextLockedChapter.teaser})`
|
||
: null,
|
||
`${label}性格:${character.personality}`,
|
||
`${label}世界属性框架:${schemaSummary}`,
|
||
`${label}主要属性:${topAttributes}`,
|
||
`${label}属性详情:${attributeDetails}`,
|
||
`${label}技能:${skills}`,
|
||
].join('\n');
|
||
}
|
||
|
||
function describeChatContext(world: WorldType, context: CharacterChatPromptContext) {
|
||
const hpRatio = context.playerHp / Math.max(context.playerMaxHp, 1);
|
||
const manaRatio = context.playerMana / Math.max(context.playerMaxMana, 1);
|
||
|
||
return [
|
||
`世界:${describeWorld(world)}`,
|
||
`玩家战斗状态:${context.inBattle ? '战斗中' : '非战斗'}`,
|
||
`场景:${context.sceneName ?? '当前区域'}`,
|
||
`场景描述:${context.sceneDescription ?? '周围气氛仍未安定。'}`,
|
||
`玩家状态:生命 ${context.playerHp}/${context.playerMaxHp}(${describeHpBand(hpRatio)}),灵力 ${context.playerMana}/${context.playerMaxMana}(${describeManaBand(manaRatio)}),朝向 ${describeFacing(context.playerFacing)},动作 ${context.playerAnimation}`,
|
||
].join('\n');
|
||
}
|
||
|
||
function describeTargetStatus(status: CharacterChatTargetStatus) {
|
||
const hpRatio = status.hp / Math.max(status.maxHp, 1);
|
||
const manaRatio = status.mana / Math.max(status.maxMana, 1);
|
||
|
||
return [
|
||
`对方身份:${status.roleLabel ?? '同行角色'}`,
|
||
`对方状态:生命 ${status.hp}/${status.maxHp}(${describeHpBand(hpRatio)}),灵力 ${status.mana}/${status.maxMana}(${describeManaBand(manaRatio)})`,
|
||
status.affinity != null ? `当前好感:${status.affinity}` : null,
|
||
].filter(Boolean).join('\n');
|
||
}
|
||
|
||
function describeCharacterChatHistory(history: CharacterChatTurn[]) {
|
||
if (history.length === 0) {
|
||
return '聊天记录:暂无。';
|
||
}
|
||
|
||
return [
|
||
'聊天记录:',
|
||
...history.slice(-12).map(turn => `- ${turn.speaker === 'player' ? '玩家' : '角色'}:${turn.text}`),
|
||
].join('\n');
|
||
}
|
||
|
||
export function buildCharacterPanelChatPrompt({
|
||
world,
|
||
playerCharacter,
|
||
targetCharacter,
|
||
storyHistory,
|
||
context,
|
||
conversationHistory,
|
||
conversationSummary,
|
||
playerMessage,
|
||
targetStatus,
|
||
}: {
|
||
world: WorldType;
|
||
playerCharacter: Character;
|
||
targetCharacter: Character;
|
||
storyHistory: StoryMoment[];
|
||
context: CharacterChatPromptContext;
|
||
conversationHistory: CharacterChatTurn[];
|
||
conversationSummary: string;
|
||
playerMessage: string;
|
||
targetStatus: CharacterChatTargetStatus;
|
||
}) {
|
||
return [
|
||
`世界:${describeWorld(world)}`,
|
||
describeChatContext(world, context),
|
||
describeCustomWorldSection(context.customWorldProfile),
|
||
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
|
||
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
|
||
affinity: targetStatus.affinity ?? null,
|
||
includeUnlockProgress: true,
|
||
}),
|
||
describeTargetStatus(targetStatus),
|
||
describeStoryHistory(storyHistory),
|
||
conversationSummary ? `之前聊天摘要:${conversationSummary}` : '之前聊天摘要:暂无。',
|
||
describeCharacterChatHistory(conversationHistory),
|
||
`玩家刚刚对 ${targetCharacter.name} 说:${playerMessage}`,
|
||
`现在请以 ${targetCharacter.name} 的身份,直接回复玩家。`,
|
||
].filter(Boolean).join('\n\n');
|
||
}
|
||
|
||
export function buildCharacterPanelChatSuggestionPrompt({
|
||
world,
|
||
playerCharacter,
|
||
targetCharacter,
|
||
storyHistory,
|
||
context,
|
||
conversationHistory,
|
||
conversationSummary,
|
||
targetStatus,
|
||
}: {
|
||
world: WorldType;
|
||
playerCharacter: Character;
|
||
targetCharacter: Character;
|
||
storyHistory: StoryMoment[];
|
||
context: CharacterChatPromptContext;
|
||
conversationHistory: CharacterChatTurn[];
|
||
conversationSummary: string;
|
||
targetStatus: CharacterChatTargetStatus;
|
||
}) {
|
||
const latestCharacterReply = [...conversationHistory]
|
||
.reverse()
|
||
.find(turn => turn.speaker === 'character')?.text ?? null;
|
||
|
||
return [
|
||
`世界:${describeWorld(world)}`,
|
||
describeChatContext(world, context),
|
||
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
|
||
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
|
||
affinity: targetStatus.affinity ?? null,
|
||
includeUnlockProgress: true,
|
||
}),
|
||
describeTargetStatus(targetStatus),
|
||
describeStoryHistory(storyHistory),
|
||
conversationSummary ? `之前聊天摘要:${conversationSummary}` : '之前聊天摘要:暂无。',
|
||
describeCharacterChatHistory(conversationHistory),
|
||
latestCharacterReply
|
||
? `角色刚刚的回复:${latestCharacterReply}`
|
||
: `玩家正准备与 ${targetCharacter.name} 开始一段新的私聊。`,
|
||
'生成 3 条可以直接发送的简短玩家回复候选。',
|
||
].filter(Boolean).join('\n\n');
|
||
}
|
||
|
||
export function buildCharacterPanelChatSummaryPrompt({
|
||
world,
|
||
playerCharacter,
|
||
targetCharacter,
|
||
storyHistory,
|
||
context,
|
||
conversationHistory,
|
||
previousSummary,
|
||
targetStatus,
|
||
}: {
|
||
world: WorldType;
|
||
playerCharacter: Character;
|
||
targetCharacter: Character;
|
||
storyHistory: StoryMoment[];
|
||
context: CharacterChatPromptContext;
|
||
conversationHistory: CharacterChatTurn[];
|
||
previousSummary: string;
|
||
targetStatus: CharacterChatTargetStatus;
|
||
}) {
|
||
return [
|
||
`世界:${describeWorld(world)}`,
|
||
describeChatContext(world, context),
|
||
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
|
||
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
|
||
affinity: targetStatus.affinity ?? null,
|
||
includeUnlockProgress: true,
|
||
}),
|
||
describeTargetStatus(targetStatus),
|
||
describeStoryHistory(storyHistory),
|
||
previousSummary ? `旧摘要:${previousSummary}` : '旧摘要:暂无。',
|
||
describeCharacterChatHistory(conversationHistory),
|
||
'请把旧摘要与最新聊天合并成一段更新后的关系摘要,供后续剧情推理使用。',
|
||
].filter(Boolean).join('\n\n');
|
||
}
|