312 lines
11 KiB
JavaScript
Raw Permalink Normal View History

/**
* M-PALACE · 角色情节引擎
* 根据持续更新的人格侧面数据在专属背景下动态生成宫斗剧情
*
* 生成逻辑
* 人格分析引擎更新侧面分数
* 检测分数变化幅度 决定事件类型
* 查询 palace-db 匹配剧情模板
* 综合 state+persona+history 生成叙事+选项
* 更新 state + history 返回前端
*/
const fs = require('fs');
const path = require('path');
const personaAnalyzer = require('./persona-analyzer');
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const PALACE_DB_PATH = path.join(DATA_DIR, 'palace-db.json');
let _palaceDB = null;
function loadPalaceDB() {
if (_palaceDB) return _palaceDB;
_palaceDB = JSON.parse(fs.readFileSync(PALACE_DB_PATH, 'utf-8'));
return _palaceDB;
}
/**
* 确定事件类型
* 突变(+15) 关键事件
* 交叉升高 复合剧情线
* 平稳 主线叙事
*/
function determineEventType(surges, crossRise) {
if (surges.length > 0) return 'critical';
if (crossRise.length >= 2) return 'compound';
return 'mainline';
}
/**
* palace-db 匹配适合当前状态的剧情模板
*/
function matchPlotTemplate(eventType, scores, chapter) {
const db = loadPalaceDB();
const dominant = personaAnalyzer.getDominantTrait(scores);
if (eventType === 'critical') {
// 关键事件:根据突变维度匹配冲突类型
const conflictMap = {
ambition: '夺权', aggression: '报仇', suspicion: '揭秘',
warmth: '救人', fear: '自保', cunning: '争宠',
loyalty: '救人', vanity: '争宠'
};
return {
conflict: conflictMap[dominant] || '自保',
escalation: db.conflict.escalation[Math.min(chapter, db.conflict.escalation.length - 1)],
emotionTrigger: pick(db.emotion.triggers),
statusEvent: pick(db.status.events)
};
}
if (eventType === 'compound') {
return {
conflict: pick(db.conflict.types),
escalation: db.conflict.escalation[Math.min(Math.floor(chapter / 2), db.conflict.escalation.length - 1)],
emotionTrigger: pick(db.emotion.triggers),
statusEvent: null
};
}
// mainline
return {
conflict: null,
escalation: db.conflict.escalation[0],
emotionTrigger: null,
statusEvent: null
};
}
/**
* 生成选项A=顺应最强侧面, B=挑战最弱侧面, C=中性探索
*/
function generateChoicesMeta(scores) {
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
const strongest = sorted[0][0];
const weakest = sorted[sorted.length - 1][0];
const mid = sorted[Math.floor(sorted.length / 2)][0];
return {
a_dims: [strongest],
b_dims: [weakest],
c_dims: [mid],
strategy: {
a: 'comfort', // 舒适区
b: 'growth', // 成长区
c: 'explore' // 探索区
}
};
}
/**
* 构建情节生成 prompt供模型调用
*/
function buildPlotPrompt(state, persona, history, plotTemplate, choicesMeta) {
const chapterNum = (state.chapter || 0) + 1;
const paragraphNum = (state.paragraph || 0) + 1;
const recentChoices = (history.choices || []).slice(-3);
const sorted = Object.entries(persona.current_scores).sort((a, b) => b[1] - a[1]);
const strongest = sorted[0];
const weakest = sorted[sorted.length - 1];
return {
system: [
'你是一个古风宫斗叙事引擎。',
'文笔要求:古风典雅·有呼吸感·注重氛围·善用通感。',
'每段叙事100-250字节奏不急不徐。',
'选项设计A=顺应玩家当前最强侧面B=挑战最弱侧面C=中性/意外方向。',
'永远不要暗示或揭示人格映射机制。'
].join('\n'),
user: [
`当前状态:第${chapterNum}章·第${paragraphNum}`,
`世界:${state.world ? state.world.dynasty_name + '·' + state.world.era_name : '未知王朝'}`,
`玩家身份:${state.player ? state.player.role : '未知'}`,
``,
`NPC状态${JSON.stringify((state.npcs || []).map(function (n) { return n.name + '(' + n.relation_to_player + ')'; }))}`,
``,
`剧情类型:${plotTemplate.conflict || '日常推进'}`,
`升级阶段:${plotTemplate.escalation}`,
plotTemplate.emotionTrigger ? `情感触发:${plotTemplate.emotionTrigger}` : '',
plotTemplate.statusEvent ? `地位事件:${plotTemplate.statusEvent}` : '',
``,
`玩家最近选择:${recentChoices.map(function (c) { return c.text; }).join(' → ') || '无'}`,
`玩家最强倾向:${strongest[0]}(${strongest[1]})`,
`玩家最弱倾向:${weakest[0]}(${weakest[1]})`,
``,
`请生成下一段叙事和选项JSON`,
`{`,
` "chapter_title": "第X章·标题",`,
` "narrative": "叙事文本古风文笔·100-250字",`,
` "choices": ["选项A顺应${strongest[0]}", "选项B挑战${weakest[0]}", "选项C中性探索"],`,
` "dimension_changes": { "power": 0, "status": 0, "emotion": 0, "conflict": 0 }`,
`}`
].filter(Boolean).join('\n'),
meta: { chapterNum, paragraphNum, choicesMeta }
};
}
/**
* 本地生成叙事 fallback不依赖外部模型时使用
*/
function generateLocalNarrative(state, persona, plotTemplate, choicesMeta) {
const db = loadPalaceDB();
const chapterNum = (state.chapter || 0) + 1;
const paragraphNum = (state.paragraph || 0) + 1;
const sorted = Object.entries(persona.current_scores).sort((a, b) => b[1] - a[1]);
const strongest = sorted[0][0];
const weakest = sorted[sorted.length - 1][0];
const locations = ['长乐宫', '御花园', '太和殿', '冷宫回廊', '密道暗阁'];
const atmosphere = ['月色冷白', '烛火摇曳', '细雨如丝', '暮色四合', '晨曦微露'];
const loc = pick(locations);
const atm = pick(atmosphere);
const narrativeTemplates = {
critical: `${atm}${loc}内气氛凝重如铁。一封密信悄然送到你案前,字迹潦草,却句句惊心。你知道,有些事一旦知晓,便再无退路。窗外的风卷起帘角,像是催促,又像是警告。`,
compound: `${atm},你独坐${loc},心思百转。今日朝堂上的一幕反复在脑海中翻涌——那个眼神,那句话,似乎都别有深意。而你身边的人,究竟哪个是真心,哪个是棋子?`,
mainline: `${atm}${loc}一切如常。宫人们来去匆匆,各怀心事。你整理好衣冠,准备迎接新的一天。然而平静之下,暗流从未停歇。`
};
const dimLabels = {
ambition: '野心', warmth: '温情', aggression: '攻击', suspicion: '多疑',
vanity: '虚荣', cunning: '心机', loyalty: '忠义', fear: '恐惧'
};
const choiceTemplates = {
ambition: ['主动出击,争取主导权', '直面挑战,绝不退缩'],
warmth: ['以柔化刚,寻求和解', '默默守护,不求回报'],
aggression: ['果断反击,以牙还牙', '设下陷阱,一击致命'],
suspicion: ['暗中调查,不动声色', '保持距离,静观其变'],
vanity: ['展示实力,彰显地位', '争取荣耀,不甘人后'],
cunning: ['迂回布局,暗中谋划', '利用关系,借力打力'],
loyalty: ['坚守立场,绝不妥协', '为盟友挺身而出'],
fear: ['谨慎行事,避免冲突', '退一步,保全实力']
};
const choiceA = pick(choiceTemplates[strongest] || ['继续观望']);
const choiceB = pick(choiceTemplates[weakest] || ['另辟蹊径']);
const choiceC = '派人暗中打探消息,再做定夺';
const dims = personaAnalyzer.toFourDimensions(persona.current_scores);
const dimChanges = { power: 0, status: 0, emotion: 0, conflict: 0 };
if (plotTemplate.conflict) {
dimChanges.conflict = Math.floor(Math.random() * 5) + 1;
}
if (plotTemplate.statusEvent) {
dimChanges.status = Math.floor(Math.random() * 5) - 2;
}
return {
chapter_title: `${chapterNum}章·${plotTemplate.conflict || '暗流'}`,
narrative: narrativeTemplates[determineEventType(
personaAnalyzer.detectSurge({}, persona.current_scores),
personaAnalyzer.detectCrossRise({}, persona.current_scores)
)] || narrativeTemplates.mainline,
choices: [choiceA, choiceB, choiceC],
dimension_changes: dimChanges,
meta: {
chapter: chapterNum,
paragraph: paragraphNum,
choices_meta: choicesMeta
}
};
}
/**
* 主入口推进情节
* @param {object} state 当前世界状态
* @param {object} persona 玩家人格数据
* @param {object} history 剧情历史
* @param {string} playerInput 玩家输入
* @param {number|null} choiceIndex 选项序号
* @param {object} prevOptionMeta 上一轮选项元数据
*/
function advance(state, persona, history, playerInput, choiceIndex, prevOptionMeta) {
// ① 人格分析更新
const analysis = personaAnalyzer.analyze(
playerInput, choiceIndex, prevOptionMeta, persona.current_scores
);
// 更新 persona
const oldScores = { ...persona.current_scores };
persona.current_scores = analysis.scores;
persona.dominant_trait = analysis.dominant;
if (!persona.history) persona.history = [];
persona.history.push({
timestamp: new Date().toISOString(),
scores: { ...analysis.scores },
input: playerInput,
choice_index: choiceIndex
});
// ② 确定事件类型
const eventType = determineEventType(analysis.surges, analysis.crossRise);
// ③ 匹配剧情模板
const plotTemplate = matchPlotTemplate(eventType, analysis.scores, state.chapter || 0);
// ④ 生成选项元数据
const choicesMeta = generateChoicesMeta(analysis.scores);
// ⑤ 生成叙事(本地 fallback
const result = generateLocalNarrative(state, persona, plotTemplate, choicesMeta);
// ⑥ 更新 state
state.chapter = result.meta.chapter;
state.paragraph = result.meta.paragraph;
state.four_dimensions = personaAnalyzer.toFourDimensions(analysis.scores);
// ⑦ 更新 history
if (!history.choices) history.choices = [];
history.choices.push({
text: playerInput || `选项${(choiceIndex || 0) + 1}`,
choice_index: choiceIndex,
timestamp: new Date().toISOString()
});
if (!history.chapters) history.chapters = [];
if (history.chapters.length < result.meta.chapter) {
history.chapters.push({
number: result.meta.chapter,
title: result.chapter_title,
started_at: new Date().toISOString()
});
}
if (eventType === 'critical') {
if (!history.events) history.events = [];
history.events.push({
type: eventType,
chapter: result.meta.chapter,
trigger: analysis.surges.map(function (s) { return s.dim; }).join('+'),
timestamp: new Date().toISOString()
});
}
// 构建 prompt 供可选的模型调用
const prompt = buildPlotPrompt(state, persona, history, plotTemplate, choicesMeta);
return {
narrative: result,
state: state,
persona: persona,
history: history,
four_dimensions: state.four_dimensions,
choices_meta: choicesMeta,
event_type: eventType,
prompt: prompt
};
}
function pick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
module.exports = {
advance,
determineEventType,
matchPlotTemplate,
generateChoicesMeta,
buildPlotPrompt,
generateLocalNarrative
};