312 lines
11 KiB
JavaScript
312 lines
11 KiB
JavaScript
/**
|
||
* 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
|
||
};
|