Merge pull request #67 from qinfendebingshuo/copilot/create-dynamic-persona-system

feat(M-PALACE): 动态人格宫廷生成系统 · 宫斗游戏模块
This commit is contained in:
冰朔 2026-03-11 21:00:14 +08:00 committed by GitHub
commit 2d03e6ee72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 3567 additions and 0 deletions

3
.gitignore vendored
View File

@ -12,3 +12,6 @@ persona-brain-db/brain.db-shm
# persona-studio runtime artifacts
persona-studio/backend/brain/model-benchmark.json
# palace-game runtime artifacts
modules/palace-game/data/saves/PAL-*

View File

@ -0,0 +1,238 @@
/**
* M-PALACE · 背景生成引擎
* 接收锚点答案 + 人格侧面初始数据
* 从宫廷数据库匹配
* 生成逻辑自洽的专属背景
*
* 生成流程
* 锚点1世界观 选择 templates/ 基础模板
* 锚点2身份 确定主角定位 + 初始人格快照
* 查询 palace-db 四大维度 调用模型 输出世界状态
*/
const fs = require('fs');
const path = require('path');
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const TEMPLATES_DIR = path.join(DATA_DIR, 'templates');
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;
}
function loadTemplate(worldview) {
const map = {
'ancient-china': 'ancient-china.json',
'古代中国': 'ancient-china.json',
'fantasy': 'fantasy-dynasty.json',
'架空王朝': 'fantasy-dynasty.json'
};
const file = map[worldview] || 'ancient-china.json';
return JSON.parse(fs.readFileSync(path.join(TEMPLATES_DIR, file), 'utf-8'));
}
/**
* 从模板中随机选取一项
*/
function pick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
/**
* 基于人格侧面决定 NPC 映射
* 核心机密每个 NPC 对应玩家 1~2 个人格侧面的外化
*/
function generateNPCMappings(scores) {
const npcs = [];
const dims = Object.entries(scores).sort((a, b) => b[1] - a[1]);
// 最强维度 → 镜像对手
const strongest = dims[0];
npcs.push({
archetype: 'mirror',
mapped_dims: [strongest[0]],
role_hint: 'rival',
description: '与你最相似的对手'
});
// 最弱维度 → 放大威胁
const weakest = dims[dims.length - 1];
npcs.push({
archetype: 'shadow',
mapped_dims: [weakest[0]],
role_hint: 'threat',
description: '让你恐惧的存在'
});
// 温情相关 → 情感锚点
if (scores.warmth >= 40) {
npcs.push({
archetype: 'anchor',
mapped_dims: ['warmth', 'loyalty'],
role_hint: 'ally',
description: '需要被保护的弱势角色'
});
}
// 多疑相关 → 暧昧盟友
if (scores.suspicion >= 45) {
npcs.push({
archetype: 'trickster',
mapped_dims: ['suspicion', 'cunning'],
role_hint: 'ambiguous',
description: '行为暧昧的盟友'
});
}
// 确保至少 3 个 NPC
if (npcs.length < 3) {
npcs.push({
archetype: 'catalyst',
mapped_dims: [dims[2][0]],
role_hint: 'neutral',
description: '推动局势变化的中立角色'
});
}
return npcs;
}
/**
* 基于人格分数查询 palace-db确定初始格局
*/
function queryInitialSetup(scores) {
const db = loadPalaceDB();
// 权力维度:基于 ambition+cunning
const powerLevel = (scores.ambition + scores.cunning) / 2;
const factionCount = powerLevel > 60 ? 3 : 2;
// 后宫地位:基于 vanity+warmth
const statusLevel = (scores.vanity + scores.warmth) / 2;
// 情感关系:基于 warmth+loyalty
const emotionIntensity = (scores.warmth + scores.loyalty) / 2;
// 矛盾冲突:基于 aggression+suspicion
const conflictLevel = (scores.aggression + scores.suspicion) / 2;
const conflictType = conflictLevel > 60 ? '争宠' : '自保';
return {
factions: db.power.factions.slice(0, factionCount),
initialEvent: pick(db.power.events),
startingRank: db.status.ranks[Math.min(Math.floor(statusLevel / 15), db.status.ranks.length - 1)],
emotionType: emotionIntensity > 60 ? pick(db.emotion.types.slice(0, 3)) : pick(db.emotion.types.slice(3)),
emotionTrigger: pick(db.emotion.triggers),
conflictType: conflictType,
escalation: db.conflict.escalation[0]
};
}
/**
* 构建世界生成 prompt供模型调用
*/
function buildWorldPrompt(template, role, setup, npcMappings) {
const dynasty = template.setting.dynasty;
const era = pick(template.setting.era_prefix);
const roleConfig = template.roles[role] || template.roles['妃子'];
const title = pick(roleConfig.title_options);
const npcDescriptions = npcMappings.map((npc, i) => {
return `NPC${i + 1}${npc.role_hint}型):${npc.description}`;
}).join('\n');
return {
system: [
'你是一个古风宫斗叙事生成器。',
'要求:文笔古风典雅,有呼吸感,不要白话流水账。',
'使用通感手法,注重氛围营造。',
'所有NPC的存在都有隐藏的人格映射逻辑但不能向玩家揭示。'
].join('\n'),
user: [
`请为以下设定生成宫廷世界开篇:`,
`王朝:${dynasty}`,
`年号:${era}`,
`玩家身份:${role}${title}`,
`初始阵营格局:${setup.factions.join('、')}`,
`开局事件:${setup.initialEvent}`,
`情感基调:${setup.emotionType}`,
`冲突类型:${setup.conflictType}`,
``,
`需要生成的NPC`,
npcDescriptions,
``,
`输出格式JSON`,
`{`,
` "dynasty_name": "王朝名",`,
` "era_name": "年号",`,
` "background": "一段时代背景描述古风文笔·100-200字",`,
` "player_intro": "主角身世与处境古风文笔·100-200字",`,
` "npcs": [{ "name": "角色名", "title": "头衔", "personality": "性格", "relation_to_player": "与主角关系", "hidden_archetype": "mirror|shadow|anchor|trickster|catalyst" }],`,
` "opening_event": "开局事件描述古风文笔·150-300字",`,
` "opening_choices": ["选项A", "选项B", "选项C"]`,
`}`
].join('\n'),
meta: { dynasty, era, title, role }
};
}
/**
* 主入口生成完整背景不依赖外部模型时使用本地模板拼接
*/
function generate(worldview, role, personaScores) {
const template = loadTemplate(worldview);
const setup = queryInitialSetup(personaScores);
const npcMappings = generateNPCMappings(personaScores);
const prompt = buildWorldPrompt(template, role, setup, npcMappings);
// 构造本地 fallback 世界(不依赖外部模型时使用)
const dynasty = prompt.meta.dynasty;
const era = prompt.meta.era;
const roleConfig = template.roles[role] || template.roles['妃子'];
const title = prompt.meta.title;
const localWorld = {
dynasty_name: dynasty,
era_name: era,
background: `${dynasty}${era}年间,朝堂暗流涌动。${setup.factions.join('与')}明争暗斗,帝位之下,人心叵测。宫墙之内,灯火通明处未必安宁,暗影幢幢处未必无情。`,
player_intro: `你是${dynasty}${title}${pick(roleConfig.family_options || ['名门'])}出身。入宫数载,${setup.conflictType === '争宠' ? '虽有圣眷,却树敌颇多' : '小心翼翼,只求自保平安'}${setup.emotionType}的暗线,早已悄然铺开。`,
npcs: npcMappings.map(function (npc, i) {
const names = ['沈婉仪', '赵昭仪', '陈太傅', '李将军', '刘公公'];
return {
name: names[i] || '无名氏',
title: pick(template.setting.era_prefix) + '年间人物',
personality: npc.description,
relation_to_player: npc.role_hint,
hidden_archetype: npc.archetype
};
}),
opening_event: `夜半三更,${template.setting.inner_palace}传来急召。太后身边的掌事姑姑面色如常,语气却带着不容拒绝的冷意。你知道,这一趟,去与不去,都是棋局的一部分。`,
opening_choices: [
'立刻更衣前往,恭顺以对',
'称身体不适,推迟半个时辰',
'先派人去打听太后的意图'
]
};
return {
world: localWorld,
npcMappings: npcMappings,
setup: setup,
prompt: prompt,
template: template
};
}
module.exports = {
generate,
loadTemplate,
loadPalaceDB,
generateNPCMappings,
queryInitialSetup,
buildWorldPrompt
};

View File

@ -0,0 +1,176 @@
/**
* M-PALACE · 人格分析引擎
* 实时分析玩家每一次输入识别并量化人格侧面
*
* 分析流程
* 关键词匹配扫描 persona-dict keywords
* 行为信号匹配判断选项类型对应的 behavior_signals
* 上下文推断结合对话历史推断语气/态度/策略倾向
* 输出 8 维人格侧面分数0~100
*/
const fs = require('fs');
const path = require('path');
const DICT_PATH = path.join(__dirname, '..', '..', 'data', 'persona-dict.json');
let _dictCache = null;
function loadDict() {
if (_dictCache) return _dictCache;
_dictCache = JSON.parse(fs.readFileSync(DICT_PATH, 'utf-8'));
return _dictCache;
}
/**
* 生成初始人格分数基于身份锚点
*/
function getInitialScores(role) {
const base = {
ambition: 50, warmth: 50, aggression: 50, suspicion: 50,
vanity: 50, cunning: 50, loyalty: 50, fear: 50
};
const adjustments = {
'皇帝': { ambition: 20, vanity: 15 },
'妃子': { warmth: 15, fear: 10 },
'重臣': { loyalty: 20, cunning: 10 },
'奸臣': { cunning: 25, aggression: 15 }
};
const adj = adjustments[role] || {};
for (const [k, v] of Object.entries(adj)) {
base[k] = Math.min(100, base[k] + v);
}
return base;
}
/**
* 关键词匹配在输入文本中扫描 persona-dict keywords
* @returns {{ [dimensionId]: number }} 命中次数 map
*/
function matchKeywords(text) {
const dict = loadDict();
const hits = {};
for (const dim of dict.dimensions) {
hits[dim.id] = 0;
for (const kw of dim.keywords) {
if (text.includes(kw)) hits[dim.id]++;
}
}
return hits;
}
/**
* 行为信号匹配将选项 index 映射到行为信号
* 选项设计规则A=最强侧面舒适区B=最弱侧面成长区C=中性
* @param {number|null} choiceIndex 0/1/2 or null(自由输入)
* @param {object} optionMeta 后端为本次选项附加的元数据 { a_dims, b_dims, c_dims }
*/
function matchBehavior(choiceIndex, optionMeta) {
if (choiceIndex === null || !optionMeta) return {};
const key = ['a_dims', 'b_dims', 'c_dims'][choiceIndex];
const dims = (optionMeta && optionMeta[key]) || [];
const hits = {};
for (const d of dims) {
hits[d] = (hits[d] || 0) + 1;
}
return hits;
}
/**
* 合并关键词 + 行为信号 更新人格分数
*/
function updateScores(currentScores, keywordHits, behaviorHits) {
const updated = { ...currentScores };
const KEYWORD_WEIGHT = 3;
const BEHAVIOR_WEIGHT = 5;
for (const dim of Object.keys(updated)) {
let delta = 0;
if (keywordHits[dim]) delta += keywordHits[dim] * KEYWORD_WEIGHT;
if (behaviorHits[dim]) delta += behaviorHits[dim] * BEHAVIOR_WEIGHT;
updated[dim] = Math.max(0, Math.min(100, updated[dim] + delta));
}
return updated;
}
/**
* 找到当前最突出特征
*/
function getDominantTrait(scores) {
let maxDim = null;
let maxVal = -1;
for (const [k, v] of Object.entries(scores)) {
if (v > maxVal) { maxVal = v; maxDim = k; }
}
return maxDim;
}
/**
* 检测分数突变单次 +15 以上
*/
function detectSurge(oldScores, newScores) {
const surges = [];
for (const dim of Object.keys(newScores)) {
const delta = newScores[dim] - (oldScores[dim] || 50);
if (delta >= 15) surges.push({ dim, delta });
}
return surges;
}
/**
* 检测交叉升高两个维度同时上升 10+
*/
function detectCrossRise(oldScores, newScores) {
const rising = [];
for (const dim of Object.keys(newScores)) {
const delta = newScores[dim] - (oldScores[dim] || 50);
if (delta >= 10) rising.push(dim);
}
return rising.length >= 2 ? rising : [];
}
/**
* 主分析入口
* @param {string} text 玩家输入文本
* @param {number|null} choiceIndex 选项序号null = 自由输入
* @param {object} optionMeta 选项元数据
* @param {object} currentScores 当前人格分数
* @returns {{ scores, dominant, surges, crossRise }}
*/
function analyze(text, choiceIndex, optionMeta, currentScores) {
const kwHits = text ? matchKeywords(text) : {};
const bhHits = matchBehavior(choiceIndex, optionMeta);
const oldScores = { ...currentScores };
const newScores = updateScores(currentScores, kwHits, bhHits);
const dominant = getDominantTrait(newScores);
const surges = detectSurge(oldScores, newScores);
const crossRise = detectCrossRise(oldScores, newScores);
return { scores: newScores, dominant, surges, crossRise };
}
/**
* 8 维人格分数转换为前端四维状态栏数值
* 权力值 = avg(ambition, cunning)
* 地位值 = avg(vanity, loyalty)
* 情感值 = avg(warmth, fear反转)
* 冲突值 = avg(aggression, suspicion)
*/
function toFourDimensions(scores) {
return {
power: Math.round((scores.ambition + scores.cunning) / 2),
status: Math.round((scores.vanity + scores.loyalty) / 2),
emotion: Math.round((scores.warmth + (100 - scores.fear)) / 2),
conflict: Math.round((scores.aggression + scores.suspicion) / 2)
};
}
module.exports = {
getInitialScores,
analyze,
getDominantTrait,
toFourDimensions,
matchKeywords,
detectSurge,
detectCrossRise
};

View File

@ -0,0 +1,311 @@
/**
* 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
};

View File

@ -0,0 +1,198 @@
/**
* M-PALACE · 存档管理器
* 打包 / 加密 / 编号 / 解密 / 恢复
*
* 存档编号规则PAL-{YYYYMMDD}-{随机4位}
* 存档结构saves/{SAVE-ID}/state.json + persona.json + history.json
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const SAVES_DIR = path.join(__dirname, '..', '..', 'data', 'saves');
// AES-256-CBC 加密密钥
const ENCRYPTION_KEY = process.env.PALACE_SAVE_KEY || (
process.env.NODE_ENV === 'production'
? (function () { throw new Error('PALACE_SAVE_KEY environment variable is required in production'); })()
: 'palace-game-dev-only-key-32ch!!'
);
const IV_LENGTH = 16;
/**
* 确保存档目录存在
*/
function ensureSavesDir() {
if (!fs.existsSync(SAVES_DIR)) {
fs.mkdirSync(SAVES_DIR, { recursive: true });
}
}
/**
* 生成存档编号PAL-{YYYYMMDD}-{随机4位字母数字}
*/
function generateSaveId() {
const now = new Date();
const dateStr = now.getFullYear().toString() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0');
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let rand = '';
for (let i = 0; i < 4; i++) {
rand += chars[Math.floor(Math.random() * chars.length)];
}
return 'PAL-' + dateStr + '-' + rand;
}
/**
* 派生 32 字节密钥
*/
function deriveKey(passphrase) {
return crypto.createHash('sha256').update(passphrase).digest();
}
/**
* AES 加密
*/
function encrypt(text) {
const key = deriveKey(ENCRYPTION_KEY);
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update(text, 'utf-8', 'base64');
encrypted += cipher.final('base64');
return iv.toString('base64') + ':' + encrypted;
}
/**
* AES 解密
*/
function decrypt(encryptedText) {
const key = deriveKey(ENCRYPTION_KEY);
const parts = encryptedText.split(':');
const iv = Buffer.from(parts[0], 'base64');
const encrypted = parts.slice(1).join(':');
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
let decrypted = decipher.update(encrypted, 'base64', 'utf-8');
decrypted += decipher.final('utf-8');
return decrypted;
}
/**
* 保存存档 state+persona+history 打包加密写入磁盘
* @returns {string} 存档编号
*/
function save(state, persona, history, existingSaveId) {
ensureSavesDir();
const saveId = existingSaveId || generateSaveId();
const saveDir = path.join(SAVES_DIR, saveId);
if (!fs.existsSync(saveDir)) {
fs.mkdirSync(saveDir, { recursive: true });
}
// 加密各文件内容后写入
const stateData = encrypt(JSON.stringify(state));
const personaData = encrypt(JSON.stringify(persona));
const historyData = encrypt(JSON.stringify(history));
fs.writeFileSync(path.join(saveDir, 'state.json'), stateData, 'utf-8');
fs.writeFileSync(path.join(saveDir, 'persona.json'), personaData, 'utf-8');
fs.writeFileSync(path.join(saveDir, 'history.json'), historyData, 'utf-8');
// 写入元数据(不加密,方便索引)
const meta = {
save_id: saveId,
created_at: new Date().toISOString(),
chapter: state.chapter || 0,
paragraph: state.paragraph || 0,
dynasty: state.world ? state.world.dynasty_name : '未知',
role: state.player ? state.player.role : '未知'
};
fs.writeFileSync(path.join(saveDir, 'meta.json'), JSON.stringify(meta, null, 2), 'utf-8');
return saveId;
}
/**
* 读取存档解密并恢复完整状态
* @param {string} saveId 存档编号
* @returns {{ state, persona, history, meta }}
*/
function load(saveId) {
const saveDir = path.join(SAVES_DIR, saveId);
if (!fs.existsSync(saveDir)) {
return null;
}
const stateEncrypted = fs.readFileSync(path.join(saveDir, 'state.json'), 'utf-8');
const personaEncrypted = fs.readFileSync(path.join(saveDir, 'persona.json'), 'utf-8');
const historyEncrypted = fs.readFileSync(path.join(saveDir, 'history.json'), 'utf-8');
const state = JSON.parse(decrypt(stateEncrypted));
const persona = JSON.parse(decrypt(personaEncrypted));
const history = JSON.parse(decrypt(historyEncrypted));
let meta = null;
const metaPath = path.join(saveDir, 'meta.json');
if (fs.existsSync(metaPath)) {
meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
}
return { state, persona, history, meta };
}
/**
* 列出所有存档
*/
function listSaves() {
ensureSavesDir();
const dirs = fs.readdirSync(SAVES_DIR).filter(function (d) {
return d.startsWith('PAL-') && fs.statSync(path.join(SAVES_DIR, d)).isDirectory();
});
return dirs.map(function (d) {
const metaPath = path.join(SAVES_DIR, d, 'meta.json');
if (fs.existsSync(metaPath)) {
return JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
}
return { save_id: d };
});
}
/**
* 删除存档
*/
function deleteSave(saveId) {
var saveDir = path.join(SAVES_DIR, saveId);
if (!fs.existsSync(saveDir)) return false;
try {
var files = fs.readdirSync(saveDir);
for (var i = 0; i < files.length; i++) {
fs.unlinkSync(path.join(saveDir, files[i]));
}
fs.rmdirSync(saveDir);
return true;
} catch (err) {
return false;
}
}
/**
* 检查存档是否存在
*/
function exists(saveId) {
return fs.existsSync(path.join(SAVES_DIR, saveId));
}
module.exports = {
save,
load,
listSaves,
deleteSave,
exists,
generateSaveId,
encrypt,
decrypt
};

View File

@ -0,0 +1,854 @@
{
"name": "palace-game-backend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "palace-game-backend",
"version": "1.0.0",
"dependencies": {
"cors": "^2.8.5",
"express": "^4.21.2"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}

View File

@ -0,0 +1,14 @@
{
"name": "palace-game-backend",
"version": "1.0.0",
"description": "M-PALACE · 动态人格宫廷生成系统 · 后端服务",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.21.2"
}
}

View File

@ -0,0 +1,84 @@
/**
* M-PALACE · 玩家互动 API
* POST /api/palace/interact
*
* 接收玩家输入 人格分析 情节推进 返回新叙事+选项
*/
const express = require('express');
const router = express.Router();
const plotEngine = require('../engines/plot-engine');
const saveManager = require('../engines/save-manager');
/**
* POST /api/palace/interact
* body: {
* save_id: "PAL-XXXXXXXX-XXXX",
* input: "玩家输入文本" | null,
* choice_index: 0|1|2|null,
* option_meta: { a_dims, b_dims, c_dims } | null
* }
*/
router.post('/', function (req, res) {
try {
var body = req.body || {};
var saveId = body.save_id;
var playerInput = body.input || '';
var choiceIndex = body.choice_index != null ? body.choice_index : null;
var optionMeta = body.option_meta || null;
if (!saveId) {
return res.status(400).json({
error: true,
code: 'MISSING_SAVE_ID',
message: '缺少存档编号'
});
}
// 读取存档
var saveData = saveManager.load(saveId);
if (!saveData) {
return res.status(404).json({
error: true,
code: 'SAVE_NOT_FOUND',
message: '存档不存在:' + saveId
});
}
var state = saveData.state;
var persona = saveData.persona;
var history = saveData.history;
// 情节推进
var result = plotEngine.advance(
state, persona, history,
playerInput, choiceIndex, optionMeta
);
// 自动存档
saveManager.save(result.state, result.persona, result.history, saveId);
res.json({
error: false,
save_id: saveId,
narrative: {
chapter_title: result.narrative.chapter_title,
narrative: result.narrative.narrative,
choices: result.narrative.choices
},
four_dimensions: result.four_dimensions,
choices_meta: result.choices_meta,
event_type: result.event_type,
chapter: result.state.chapter,
paragraph: result.state.paragraph
});
} catch (err) {
res.status(500).json({
error: true,
code: 'INTERACT_ERROR',
message: '剧情推进失败:' + err.message
});
}
});
module.exports = router;

View File

@ -0,0 +1,113 @@
/**
* M-PALACE · 存档/读档 API
* POST /api/palace/save 保存当前状态
* POST /api/palace/load 读取存档
* GET /api/palace/saves 列出所有存档
*/
const express = require('express');
const router = express.Router();
const saveManager = require('../engines/save-manager');
/**
* POST /api/palace/save
* body: { save_id, state, persona, history }
*/
router.post('/', function (req, res) {
try {
var body = req.body || {};
var saveId = body.save_id;
var state = body.state;
var persona = body.persona;
var history = body.history;
if (!state || !persona || !history) {
return res.status(400).json({
error: true,
code: 'MISSING_DATA',
message: '缺少存档数据'
});
}
var resultId = saveManager.save(state, persona, history, saveId);
res.json({
error: false,
save_id: resultId,
message: '存档成功',
saved_at: new Date().toISOString()
});
} catch (err) {
res.status(500).json({
error: true,
code: 'SAVE_ERROR',
message: '存档失败:' + err.message
});
}
});
/**
* POST /api/palace/load
* body: { save_id: "PAL-XXXXXXXX-XXXX" }
*/
router.post('/load', function (req, res) {
try {
var body = req.body || {};
var saveId = body.save_id;
if (!saveId) {
return res.status(400).json({
error: true,
code: 'MISSING_SAVE_ID',
message: '请输入存档编号'
});
}
var saveData = saveManager.load(saveId);
if (!saveData) {
return res.status(404).json({
error: true,
code: 'SAVE_NOT_FOUND',
message: '存档不存在:' + saveId
});
}
res.json({
error: false,
save_id: saveId,
state: saveData.state,
persona: saveData.persona,
history: saveData.history,
meta: saveData.meta
});
} catch (err) {
res.status(500).json({
error: true,
code: 'LOAD_ERROR',
message: '读档失败:' + err.message
});
}
});
/**
* GET /api/palace/saves
*/
router.get('/list', function (_req, res) {
try {
var saves = saveManager.listSaves();
res.json({
error: false,
saves: saves,
count: saves.length
});
} catch (err) {
res.status(500).json({
error: true,
code: 'LIST_ERROR',
message: '获取存档列表失败:' + err.message
});
}
});
module.exports = router;

View File

@ -0,0 +1,94 @@
/**
* M-PALACE · 快速启动 API
* POST /api/palace/start
*
* 接收世界观+身份锚点 生成初始人格快照 背景生成 返回世界状态
*/
const express = require('express');
const router = express.Router();
const personaAnalyzer = require('../engines/persona-analyzer');
const backgroundGen = require('../engines/background-gen');
const saveManager = require('../engines/save-manager');
/**
* POST /api/palace/start
* body: { worldview: "古代中国"|"架空王朝", role: "妃子"|"皇帝"|"重臣"|"奸臣"|"随机" }
*/
router.post('/', function (req, res) {
try {
var body = req.body || {};
var worldview = body.worldview || '古代中国';
var role = body.role || '随机';
// 随机角色
if (role === '随机') {
var roles = ['妃子', '皇帝', '重臣', '奸臣'];
role = roles[Math.floor(Math.random() * roles.length)];
}
// ① 基于身份锚点生成初始人格分数
var initialScores = personaAnalyzer.getInitialScores(role);
// ② 背景生成引擎
var bgResult = backgroundGen.generate(worldview, role, initialScores);
// ③ 构建初始状态
var state = {
world: bgResult.world,
npcs: bgResult.world.npcs || [],
player: {
role: role,
title: bgResult.world.player_intro,
worldview: worldview
},
chapter: 1,
paragraph: 1,
four_dimensions: personaAnalyzer.toFourDimensions(initialScores)
};
var persona = {
current_scores: initialScores,
history: [{
timestamp: new Date().toISOString(),
scores: { ...initialScores },
input: '__init__',
choice_index: null
}],
dominant_trait: personaAnalyzer.getDominantTrait(initialScores)
};
var history = {
chapters: [{
number: 1,
title: '第一章·' + (bgResult.world.dynasty_name || '序幕'),
started_at: new Date().toISOString()
}],
events: [],
choices: []
};
// ④ 自动存档
var saveId = saveManager.save(state, persona, history);
res.json({
error: false,
save_id: saveId,
state: state,
narrative: {
chapter_title: history.chapters[0].title,
narrative: bgResult.world.opening_event,
choices: bgResult.world.opening_choices
},
four_dimensions: state.four_dimensions
});
} catch (err) {
res.status(500).json({
error: true,
code: 'START_ERROR',
message: '宫廷世界生成失败:' + err.message
});
}
});
module.exports = router;

View File

@ -0,0 +1,59 @@
/**
* M-PALACE · Express 主服务
* 动态人格宫廷生成系统 · 后端核心
*
* API 路由
* POST /api/palace/start 快速启动生成世界
* POST /api/palace/interact 玩家互动剧情推进
* POST /api/palace/save 存档
* POST /api/palace/load 读档
* GET /api/palace/saves 存档列表
* GET /api/palace/health 健康检查
*/
const express = require('express');
const cors = require('cors');
const path = require('path');
const startRoutes = require('./routes/start');
const interactRoutes = require('./routes/interact');
const saveRoutes = require('./routes/save');
const app = express();
const PORT = process.env.PALACE_PORT || 3003;
// 中间件
app.use(cors());
app.use(express.json({ limit: '2mb' }));
// 静态文件:前端
app.use('/palace-game', express.static(path.join(__dirname, '..', 'frontend')));
// API 路由
app.use('/api/palace/start', startRoutes);
app.use('/api/palace/interact', interactRoutes);
app.use('/api/palace/save', saveRoutes);
// 健康检查
app.get('/api/palace/health', function (_req, res) {
res.json({
status: 'ok',
service: 'palace-game',
version: '1.0.0',
timestamp: new Date().toISOString()
});
});
// 根路由 → 前端入口
app.get('/', function (_req, res) {
res.redirect('/palace-game/index.html');
});
// 启动服务
if (require.main === module) {
app.listen(PORT, function () {
console.log('🏯 M-PALACE · 宫廷纪服务启动 · 端口 ' + PORT);
});
}
module.exports = app;

View File

@ -0,0 +1,19 @@
{
"power": {
"positions": ["皇帝", "太后", "皇后", "贵妃", "丞相", "大将军", "太监总管"],
"factions": ["太后党", "皇后党", "新贵派", "清流派", "孤臣"],
"events": ["朝堂弹劾", "密谋政变", "联姻结盟", "战事告急"]
},
"status": {
"ranks": ["答应", "常在", "贵人", "嫔", "妃", "贵妃", "皇贵妃", "皇后"],
"events": ["晋封", "降位", "禁足", "受宠", "失宠", "怀孕", "小产"]
},
"emotion": {
"types": ["暗恋", "深情", "嫉妒", "怨恨", "愧疚", "依赖", "背叛"],
"triggers": ["偶遇旧人", "收到密信", "目睹真相", "被迫抉择"]
},
"conflict": {
"types": ["争宠", "夺权", "报仇", "自保", "救人", "揭秘"],
"escalation": ["暗示", "试探", "交锋", "撕破脸", "鱼死网破"]
}
}

View File

@ -0,0 +1,52 @@
{
"dimensions": [
{
"id": "ambition",
"name": "野心",
"keywords": ["权力", "登上", "掌控", "不甘", "凭什么"],
"behavior_signals": ["选择攻击性选项", "主动挑战权威", "拒绝退让"]
},
{
"id": "warmth",
"name": "温情",
"keywords": ["保护", "心疼", "陪伴", "不忍", "算了"],
"behavior_signals": ["选择和解", "保护弱势角色", "牺牲利益"]
},
{
"id": "aggression",
"name": "攻击性",
"keywords": ["杀", "除掉", "报复", "不放过", "代价"],
"behavior_signals": ["选择对抗", "设计陷阱", "直接冲突"]
},
{
"id": "suspicion",
"name": "多疑",
"keywords": ["试探", "不对劲", "背后", "真的吗", "骗"],
"behavior_signals": ["频繁试探", "不信任NPC", "选择观望"]
},
{
"id": "vanity",
"name": "虚荣",
"keywords": ["面子", "配得上", "最好的", "羡慕", "风光"],
"behavior_signals": ["追求地位象征", "在意他人评价", "选择炫耀"]
},
{
"id": "cunning",
"name": "心机",
"keywords": ["计划", "利用", "棋子", "布局", "暗中"],
"behavior_signals": ["迂回策略", "利用他人", "长线布局"]
},
{
"id": "loyalty",
"name": "忠义",
"keywords": ["信任", "誓言", "守护", "不背叛", "承诺"],
"behavior_signals": ["坚守阵营", "拒绝背叛", "为盟友冒险"]
},
{
"id": "fear",
"name": "恐惧",
"keywords": ["害怕", "不敢", "万一", "小心", "退"],
"behavior_signals": ["回避冲突", "选择保守", "犹豫不决"]
}
]
}

View File

View File

@ -0,0 +1,40 @@
{
"id": "ancient-china",
"name": "古代中国",
"description": "大周王朝·正统朝堂·后宫嫔妃·权臣博弈",
"setting": {
"dynasty": "大周",
"era_prefix": ["永安", "承平", "天启", "景和", "元丰"],
"capital": "长安",
"palace_name": "未央宫",
"inner_palace": "长乐宫"
},
"roles": {
"妃子": {
"title_options": ["淑妃", "德妃", "贤妃", "丽嫔", "婉嫔"],
"family_options": ["书香门第", "武将世家", "寒门出身", "没落贵族"],
"starting_rank": "嫔",
"intro_template": "你是{family}出身的{title},入宫{years}年。{situation}"
},
"皇帝": {
"title_options": ["新帝", "少年天子", "中兴之主"],
"situation_options": ["初登大宝·朝局未稳", "亲政在即·太后干政", "盛世之君·暗流涌动"],
"intro_template": "你是{dynasty}的{title}{situation}。"
},
"重臣": {
"title_options": ["丞相", "御史大夫", "太傅", "兵部尚书"],
"faction_options": ["清流派", "保皇派", "中立派"],
"intro_template": "你是{dynasty}{title}{faction}中流砥柱。{situation}"
},
"奸臣": {
"title_options": ["内阁首辅", "司礼监掌印", "锦衣卫指挥使"],
"ambition_options": ["权倾朝野", "挟天子以令诸侯", "暗中培植势力"],
"intro_template": "你是{title},表面忠良,实则{ambition}。"
}
},
"atmosphere": {
"time_of_day": ["寅时·天未亮", "辰时·早朝", "午时·日正中", "酉时·暮色", "亥时·深夜"],
"weather": ["细雨绵绵", "月朗星稀", "寒风凛冽", "春光明媚", "大雪纷飞"],
"locations": ["御书房", "太和殿", "长乐宫", "御花园", "冷宫", "密道", "城墙"]
}
}

View File

@ -0,0 +1,40 @@
{
"id": "fantasy-dynasty",
"name": "架空王朝",
"description": "九霄帝国·灵力与权谋交织·玄幻宫廷",
"setting": {
"dynasty": "九霄",
"era_prefix": ["灵元", "天衍", "混沌", "太初", "紫极"],
"capital": "天枢城",
"palace_name": "凌霄殿",
"inner_palace": "瑶光阁"
},
"roles": {
"妃子": {
"title_options": ["星妃", "月嫔", "霜华夫人", "云裳贵人"],
"family_options": ["上古灵族后裔", "剑修世家", "炼丹宗门", "凡人修仙"],
"starting_rank": "嫔",
"intro_template": "你是{family}出身的{title},入宫{years}年。{situation}"
},
"皇帝": {
"title_options": ["灵帝", "天选之主", "末代帝君"],
"situation_options": ["灵力觉醒·诸族觊觎", "仙魔大战后·百废待兴", "天命将尽·寻找继承"],
"intro_template": "你是{dynasty}的{title}{situation}。"
},
"重臣": {
"title_options": ["星辰阁大长老", "天机院主", "护国神将", "灵枢司正"],
"faction_options": ["仙道派", "人道派", "中立长老会"],
"intro_template": "你是{dynasty}{title}{faction}核心。{situation}"
},
"奸臣": {
"title_options": ["暗影阁主", "禁术研究者", "外域使者"],
"ambition_options": ["窃取帝国灵脉", "复活远古邪神", "暗中操控傀儡帝"],
"intro_template": "你是{title},表面忠良,实则{ambition}。"
}
},
"atmosphere": {
"time_of_day": ["灵潮初涌·黎明", "日中·灵力最盛", "黄昏·阴阳交替", "子夜·暗灵活跃"],
"weather": ["灵雨飘洒", "星河低垂", "雷灵暴动", "万里晴空·灵气充沛", "迷雾笼罩"],
"locations": ["灵枢大殿", "星象台", "瑶光阁", "灵兽园", "封印之地", "虚空裂缝", "万灵池"]
}
}

View File

@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宫廷纪 · 叙事</title>
<link rel="stylesheet" href="style.css">
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600&display=swap" rel="stylesheet">
</head>
<body>
<div class="game-page">
<!-- Top Bar -->
<div class="top-bar">
<span class="top-bar-title" id="top-title">🏯 宫廷纪</span>
<div class="top-bar-actions">
<button class="btn btn-small" id="btn-save">存档</button>
<button class="btn btn-small" id="btn-exit" onclick="window.location.href='index.html'">退出</button>
</div>
</div>
<!-- Narrative Area -->
<div class="narrative-area fade-in">
<h2 class="chapter-title" id="chapter-title">第一章 · 序幕</h2>
<div class="narrative-text serif" id="narrative-text">
<span class="loading">
<span class="loading-dot"></span>
<span class="loading-dot"></span>
<span class="loading-dot"></span>
</span>
</div>
</div>
<!-- Choices Area -->
<div class="choices-area" id="choices-area">
<p class="choices-label">┌ 选项区 ─</p>
<div id="choices-container"></div>
<div class="free-input-row">
<input type="text" id="free-input" placeholder="④ 自由输入你的回应…">
<button class="btn btn-small" id="btn-free-submit">发送</button>
</div>
</div>
<!-- Status Bar -->
<div class="status-bar" id="status-bar">
<div class="status-toggle" id="status-toggle">▼ 状态栏</div>
<div class="status-grid" id="status-grid">
<div class="stat-row">
<span class="stat-icon">🔴</span>
<span class="stat-label">权力值</span>
<div class="stat-bar-bg"><div class="stat-bar-fill power" id="bar-power" style="width:50%"></div></div>
<span class="stat-value" id="val-power">50</span>
</div>
<div class="stat-row">
<span class="stat-icon">💜</span>
<span class="stat-label">地位值</span>
<div class="stat-bar-bg"><div class="stat-bar-fill status" id="bar-status" style="width:50%"></div></div>
<span class="stat-value" id="val-status">50</span>
</div>
<div class="stat-row">
<span class="stat-icon">💛</span>
<span class="stat-label">情感值</span>
<div class="stat-bar-bg"><div class="stat-bar-fill emotion" id="bar-emotion" style="width:50%"></div></div>
<span class="stat-value" id="val-emotion">50</span>
</div>
<div class="stat-row">
<span class="stat-icon">⚔️</span>
<span class="stat-label">冲突值</span>
<div class="stat-bar-bg"><div class="stat-bar-fill conflict" id="bar-conflict" style="width:50%"></div></div>
<span class="stat-value" id="val-conflict">50</span>
</div>
</div>
</div>
<!-- Bottom Bar -->
<div class="bottom-bar">
<span id="progress-info">📖 第1章 · 第1段</span>
<span id="save-id-display">存档号: —</span>
</div>
</div>
<script src="game.js"></script>
</body>
</html>

View File

@ -0,0 +1,311 @@
/**
* M-PALACE · 游戏交互逻辑
* 管理叙事渲染选项交互状态栏更新存档操作
*/
(function () {
'use strict';
var API_BASE = window.location.origin;
var saveId = sessionStorage.getItem('palace_save_id') || '';
var choicesMeta = null;
var isTyping = false;
// ---------- Initialization ----------
function init() {
var startData = sessionStorage.getItem('palace_state');
var loadedData = sessionStorage.getItem('palace_loaded');
if (startData) {
var data = JSON.parse(startData);
sessionStorage.removeItem('palace_state');
renderGameData(data);
} else if (loadedData) {
var loaded = JSON.parse(loadedData);
sessionStorage.removeItem('palace_loaded');
renderLoadedData(loaded);
} else if (saveId) {
loadSave(saveId);
} else {
window.location.href = 'index.html';
}
bindEvents();
}
// ---------- Rendering ----------
function renderGameData(data) {
saveId = data.save_id || saveId;
document.getElementById('save-id-display').textContent = '存档号: ' + saveId;
if (data.state && data.state.player) {
var p = data.state.player;
document.getElementById('top-title').textContent =
'🏯 宫廷纪 · ' + (p.worldview || '') + ' · ' + (p.role || '');
}
if (data.narrative) {
renderNarrative(data.narrative);
}
if (data.four_dimensions) {
updateStatusBar(data.four_dimensions);
}
updateProgressInfo(data.chapter || 1, data.paragraph || 1);
}
function renderLoadedData(data) {
document.getElementById('save-id-display').textContent = '存档号: ' + saveId;
if (data.state) {
var s = data.state;
if (s.player) {
document.getElementById('top-title').textContent =
'🏯 宫廷纪 · ' + (s.player.worldview || '') + ' · ' + (s.player.role || '');
}
if (s.four_dimensions) {
updateStatusBar(s.four_dimensions);
}
updateProgressInfo(s.chapter || 1, s.paragraph || 1);
}
// Render last narrative from history or show resume message
var narrativeEl = document.getElementById('narrative-text');
narrativeEl.textContent = '存档已恢复。继续你的宫廷故事……';
renderChoices(['继续前行', '回顾往事', '观察四周']);
}
function renderNarrative(narrative) {
if (narrative.chapter_title) {
document.getElementById('chapter-title').textContent = narrative.chapter_title;
}
var textEl = document.getElementById('narrative-text');
if (narrative.narrative) {
typewriter(textEl, narrative.narrative);
}
if (narrative.choices && narrative.choices.length > 0) {
// Delay choices until typewriter finishes
var delay = narrative.narrative ? narrative.narrative.length * 50 + 500 : 200;
setTimeout(function () {
renderChoices(narrative.choices);
}, delay);
}
choicesMeta = narrative.choices_meta || null;
}
function renderChoices(choices) {
var container = document.getElementById('choices-container');
container.innerHTML = '';
var nums = ['①', '②', '③'];
choices.forEach(function (text, i) {
var btn = document.createElement('button');
btn.className = 'choice-btn fade-in';
btn.innerHTML = '<span class="choice-num">' + (nums[i] || '·') + '</span>' + escapeHtml(text);
btn.addEventListener('click', function () {
submitChoice(i, text);
});
container.appendChild(btn);
});
}
// ---------- Typewriter Effect ----------
function typewriter(el, text) {
isTyping = true;
el.textContent = '';
var cursor = document.createElement('span');
cursor.className = 'typewriter-cursor';
el.appendChild(cursor);
var i = 0;
var interval = setInterval(function () {
if (i < text.length) {
el.insertBefore(document.createTextNode(text[i]), cursor);
i++;
} else {
clearInterval(interval);
if (cursor.parentNode) cursor.remove();
isTyping = false;
}
}, 45);
}
// ---------- Status Bar ----------
function updateStatusBar(dims) {
var keys = ['power', 'status', 'emotion', 'conflict'];
keys.forEach(function (k) {
var val = dims[k] != null ? dims[k] : 50;
var bar = document.getElementById('bar-' + k);
var valEl = document.getElementById('val-' + k);
if (bar) bar.style.width = val + '%';
if (valEl) valEl.textContent = val;
});
}
function updateProgressInfo(chapter, paragraph) {
var el = document.getElementById('progress-info');
if (el) el.textContent = '📖 第' + chapter + '章 · 第' + paragraph + '段';
}
// ---------- Interactions ----------
function submitChoice(index, text) {
if (isTyping) return;
disableChoices();
fetch(API_BASE + '/api/palace/interact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
save_id: saveId,
input: text,
choice_index: index,
option_meta: choicesMeta
})
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.error) {
showToast(data.message || '互动失败');
enableChoices();
return;
}
choicesMeta = data.choices_meta || null;
if (data.narrative) renderNarrative(data.narrative);
if (data.four_dimensions) updateStatusBar(data.four_dimensions);
updateProgressInfo(data.chapter || 1, data.paragraph || 1);
})
.catch(function (err) {
showToast('网络错误:' + err.message);
enableChoices();
});
}
function submitFreeInput() {
var input = document.getElementById('free-input');
var text = input.value.trim();
if (!text || isTyping) return;
input.value = '';
disableChoices();
fetch(API_BASE + '/api/palace/interact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
save_id: saveId,
input: text,
choice_index: null,
option_meta: choicesMeta
})
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.error) {
showToast(data.message || '互动失败');
enableChoices();
return;
}
choicesMeta = data.choices_meta || null;
if (data.narrative) renderNarrative(data.narrative);
if (data.four_dimensions) updateStatusBar(data.four_dimensions);
updateProgressInfo(data.chapter || 1, data.paragraph || 1);
})
.catch(function (err) {
showToast('网络错误:' + err.message);
enableChoices();
});
}
function disableChoices() {
var btns = document.querySelectorAll('.choice-btn');
btns.forEach(function (b) { b.disabled = true; b.style.opacity = '0.5'; });
}
function enableChoices() {
var btns = document.querySelectorAll('.choice-btn');
btns.forEach(function (b) { b.disabled = false; b.style.opacity = '1'; });
}
// ---------- Save / Load ----------
function doSave() {
fetch(API_BASE + '/api/palace/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ save_id: saveId, state: {}, persona: {}, history: {} })
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.error) { showToast(data.message); return; }
showToast('存档成功:' + data.save_id);
})
.catch(function (err) {
showToast('存档失败:' + err.message);
});
}
function loadSave(id) {
fetch(API_BASE + '/api/palace/save/load', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ save_id: id })
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.error) {
showToast(data.message || '读档失败');
window.location.href = 'index.html';
return;
}
renderLoadedData(data);
})
.catch(function (err) {
showToast('网络错误:' + err.message);
window.location.href = 'index.html';
});
}
// ---------- Events ----------
function bindEvents() {
document.getElementById('btn-save').addEventListener('click', doSave);
document.getElementById('btn-free-submit').addEventListener('click', submitFreeInput);
document.getElementById('free-input').addEventListener('keydown', function (e) {
if (e.key === 'Enter') submitFreeInput();
});
var toggle = document.getElementById('status-toggle');
var bar = document.getElementById('status-bar');
toggle.addEventListener('click', function () {
bar.classList.toggle('collapsed');
toggle.textContent = bar.classList.contains('collapsed') ? '▶ 状态栏' : '▼ 状态栏';
});
}
// ---------- Utilities ----------
function escapeHtml(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
function showToast(msg) {
var old = document.querySelector('.toast');
if (old) old.remove();
var el = document.createElement('div');
el.className = 'toast';
el.textContent = msg;
document.body.appendChild(el);
setTimeout(function () { if (el.parentNode) el.remove(); }, 3000);
}
// ---------- Boot ----------
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宫·廷·纪 — 动态人格宫廷生成系统</title>
<link rel="stylesheet" href="style.css">
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600&display=swap" rel="stylesheet">
</head>
<body>
<div class="start-page">
<div class="start-icon">🏯</div>
<h1 class="start-title">宫 · 廷 · 纪</h1>
<p class="start-subtitle">动态人格宫廷生成系统</p>
<div class="start-card">
<div class="form-group">
<label for="worldview">选择你的世界观</label>
<select id="worldview">
<option value="古代中国">古代中国</option>
<option value="架空王朝">架空王朝</option>
</select>
</div>
<div class="form-group">
<label for="role">选择你的身份</label>
<select id="role">
<option value="随机">随机</option>
<option value="妃子">妃子</option>
<option value="皇帝">皇帝</option>
<option value="重臣">重臣</option>
<option value="奸臣">奸臣</option>
</select>
</div>
<button class="btn btn-primary" id="btn-start">🏯 踏入宫廷</button>
</div>
<div class="load-section">
<div class="divider"></div>
<p class="divider-text">已有存档?输入编号继续</p>
<div class="load-row">
<input type="text" id="save-id-input" placeholder="PAL-20260311-A3K9">
<button class="btn btn-small" id="btn-load">读档</button>
</div>
</div>
<p class="start-footer">🏮 HoloLake Era · 光湖语言人格系统</p>
</div>
<script>
(function () {
var API_BASE = window.location.origin;
document.getElementById('btn-start').addEventListener('click', function () {
var worldview = document.getElementById('worldview').value;
var role = document.getElementById('role').value;
var btn = this;
btn.disabled = true;
btn.textContent = '正在生成宫廷世界…';
fetch(API_BASE + '/api/palace/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ worldview: worldview, role: role })
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.error) {
showToast('生成失败:' + data.message);
btn.disabled = false;
btn.textContent = '🏯 踏入宫廷';
return;
}
sessionStorage.setItem('palace_save_id', data.save_id);
sessionStorage.setItem('palace_state', JSON.stringify(data));
window.location.href = 'game.html';
})
.catch(function (err) {
showToast('网络错误:' + err.message);
btn.disabled = false;
btn.textContent = '🏯 踏入宫廷';
});
});
document.getElementById('btn-load').addEventListener('click', function () {
var saveId = document.getElementById('save-id-input').value.trim();
if (!saveId) { showToast('请输入存档编号'); return; }
fetch(API_BASE + '/api/palace/save/load', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ save_id: saveId })
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.error) {
showToast(data.message || '读档失败');
return;
}
sessionStorage.setItem('palace_save_id', saveId);
sessionStorage.setItem('palace_loaded', JSON.stringify(data));
window.location.href = 'game.html';
})
.catch(function (err) {
showToast('网络错误:' + err.message);
});
});
function showToast(msg) {
var old = document.querySelector('.toast');
if (old) old.remove();
var el = document.createElement('div');
el.className = 'toast';
el.textContent = msg;
document.body.appendChild(el);
setTimeout(function () { if (el.parentNode) el.remove(); }, 3000);
}
})();
</script>
</body>
</html>

View File

@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宫廷纪 · 存档管理</title>
<link rel="stylesheet" href="style.css">
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600&display=swap" rel="stylesheet">
</head>
<body>
<div class="save-page container">
<h1>🏯 存档管理</h1>
<div class="divider"></div>
<!-- Manual Load -->
<div class="start-card" style="margin: 0 auto 2rem; max-width:500px;">
<div class="form-group">
<label for="load-id">输入存档编号读档</label>
<div class="load-row">
<input type="text" id="load-id" placeholder="PAL-20260311-A3K9">
<button class="btn btn-small" id="btn-load">读档</button>
</div>
</div>
</div>
<div class="divider"></div>
<!-- Save List -->
<h2 style="text-align:center; margin-bottom:1rem; font-size:1.1rem;">已有存档</h2>
<div class="save-list" id="save-list">
<div class="loading">
<span class="loading-dot"></span>
<span class="loading-dot"></span>
<span class="loading-dot"></span>
</div>
</div>
<div class="divider" style="margin-top:2rem;"></div>
<div style="text-align:center;">
<button class="btn btn-small" onclick="window.location.href='index.html'">← 返回首页</button>
</div>
</div>
<script src="save.js"></script>
</body>
</html>

View File

@ -0,0 +1,139 @@
/**
* M-PALACE · 存档读档逻辑
* 列出存档读取存档删除存档
*/
(function () {
'use strict';
var API_BASE = window.location.origin;
function init() {
loadSaveList();
bindEvents();
}
// ---------- Load Save List ----------
function loadSaveList() {
var container = document.getElementById('save-list');
fetch(API_BASE + '/api/palace/save/list')
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.error) {
container.innerHTML = '<div class="empty-state">获取存档列表失败</div>';
return;
}
renderSaveList(data.saves || []);
})
.catch(function () {
container.innerHTML = '<div class="empty-state">无法连接服务器</div>';
});
}
function renderSaveList(saves) {
var container = document.getElementById('save-list');
if (saves.length === 0) {
container.innerHTML = '<div class="empty-state">暂无存档</div>';
return;
}
container.innerHTML = '';
saves.forEach(function (save) {
var item = document.createElement('div');
item.className = 'save-item fade-in';
var info = document.createElement('div');
info.className = 'save-info';
var idEl = document.createElement('div');
idEl.className = 'save-id';
idEl.textContent = save.save_id;
var detail = document.createElement('div');
detail.className = 'save-detail';
var parts = [];
if (save.dynasty) parts.push(save.dynasty);
if (save.role) parts.push(save.role);
if (save.chapter) parts.push('第' + save.chapter + '章');
if (save.created_at) parts.push(save.created_at.slice(0, 16).replace('T', ' '));
detail.textContent = parts.join(' · ');
info.appendChild(idEl);
info.appendChild(detail);
var actions = document.createElement('div');
actions.className = 'save-actions';
var loadBtn = document.createElement('button');
loadBtn.className = 'btn btn-small';
loadBtn.textContent = '读档';
loadBtn.addEventListener('click', function () {
doLoad(save.save_id);
});
actions.appendChild(loadBtn);
item.appendChild(info);
item.appendChild(actions);
container.appendChild(item);
});
}
// ---------- Load Save ----------
function doLoad(saveId) {
fetch(API_BASE + '/api/palace/save/load', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ save_id: saveId })
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.error) {
showToast(data.message || '读档失败');
return;
}
sessionStorage.setItem('palace_save_id', saveId);
sessionStorage.setItem('palace_loaded', JSON.stringify(data));
window.location.href = 'game.html';
})
.catch(function (err) {
showToast('网络错误:' + err.message);
});
}
// ---------- Events ----------
function bindEvents() {
document.getElementById('btn-load').addEventListener('click', function () {
var id = document.getElementById('load-id').value.trim();
if (!id) { showToast('请输入存档编号'); return; }
doLoad(id);
});
document.getElementById('load-id').addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
var id = this.value.trim();
if (id) doLoad(id);
}
});
}
// ---------- Utilities ----------
function showToast(msg) {
var old = document.querySelector('.toast');
if (old) old.remove();
var el = document.createElement('div');
el.className = 'toast';
el.textContent = msg;
document.body.appendChild(el);
setTimeout(function () { if (el.parentNode) el.remove(); }, 3000);
}
// ---------- Boot ----------
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@ -0,0 +1,564 @@
/* ====================================
M-PALACE · 宫廷视觉风格
暗红+金色主题 · 古风字体 · 逐字动画
==================================== */
/* ---------- CSS Variables ---------- */
:root {
--bg-primary: #1a0a0a;
--bg-secondary: #0d0d1a;
--gold: #c9a96e;
--gold-light: #e8d5a3;
--danger: #8b0000;
--text-primary: #f5f0e8;
--text-secondary:#a0937d;
--border-gold: rgba(201,169,110,0.15);
--border-gold-md:rgba(201,169,110,0.3);
--glow-gold: rgba(201,169,110,0.25);
}
/* ---------- Reset & Base ---------- */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
line-height: 1.6;
overflow-x: hidden;
}
/* ---------- Background Texture ---------- */
body::before {
content: '';
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background:
radial-gradient(ellipse at 20% 50%, rgba(139,0,0,0.08) 0%, transparent 60%),
radial-gradient(ellipse at 80% 20%, rgba(201,169,110,0.05) 0%, transparent 50%),
radial-gradient(ellipse at 50% 80%, rgba(13,13,26,0.6) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
}
/* ---------- Layout ---------- */
.container {
max-width: 720px;
margin: 0 auto;
padding: 2rem 1.5rem;
position: relative;
z-index: 1;
}
/* ---------- Typography ---------- */
.serif {
font-family: 'Noto Serif SC', 'Source Han Serif CN', 'STSong', serif;
}
h1, h2, h3 { color: var(--gold); font-weight: 400; }
/* ---------- Golden Divider ---------- */
.divider {
height: 1px;
background: linear-gradient(90deg, transparent, var(--gold), transparent);
margin: 1.5rem 0;
opacity: 0.4;
}
.divider-text {
text-align: center;
color: var(--text-secondary);
font-size: 0.85rem;
padding: 0.5rem 0;
}
/* ====================================
INDEX PAGE Quick Start
==================================== */
.start-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
text-align: center;
padding: 2rem;
}
.start-icon {
font-size: 4rem;
margin-bottom: 1rem;
filter: drop-shadow(0 0 20px var(--glow-gold));
}
.start-title {
font-family: 'Noto Serif SC', 'Source Han Serif CN', serif;
font-size: 2.2rem;
letter-spacing: 0.8rem;
color: var(--gold);
margin-bottom: 0.3rem;
}
.start-subtitle {
font-size: 0.9rem;
color: var(--text-secondary);
margin-bottom: 2.5rem;
letter-spacing: 0.2rem;
}
/* Form Card */
.start-card {
background: rgba(26,10,10,0.85);
border: 1px solid var(--border-gold);
border-radius: 8px;
padding: 2rem 2.5rem;
width: 100%;
max-width: 420px;
backdrop-filter: blur(8px);
}
.form-group {
margin-bottom: 1.5rem;
text-align: left;
}
.form-group label {
display: block;
font-size: 0.9rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
select, input[type="text"] {
width: 100%;
padding: 0.7rem 1rem;
background: rgba(13,13,26,0.6);
border: 1px solid var(--border-gold-md);
border-radius: 4px;
color: var(--text-primary);
font-size: 0.95rem;
font-family: inherit;
outline: none;
transition: border-color 0.3s;
}
select:focus, input[type="text"]:focus {
border-color: var(--gold);
box-shadow: 0 0 8px var(--glow-gold);
}
select option {
background: var(--bg-primary);
color: var(--text-primary);
}
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 2rem;
border: 1px solid var(--gold);
border-radius: 4px;
background: rgba(139,0,0,0.3);
color: var(--gold);
font-size: 1rem;
font-family: 'Noto Serif SC', 'Source Han Serif CN', serif;
cursor: pointer;
transition: all 0.3s;
letter-spacing: 0.15rem;
}
.btn:hover {
background: rgba(139,0,0,0.6);
box-shadow: 0 0 16px var(--glow-gold);
transform: translateY(-1px);
}
.btn:active { transform: translateY(0); }
.btn-primary {
width: 100%;
padding: 1rem;
font-size: 1.1rem;
margin-top: 0.5rem;
}
.btn-small {
padding: 0.5rem 1.2rem;
font-size: 0.85rem;
}
/* Load Section */
.load-section {
width: 100%;
max-width: 420px;
margin-top: 1rem;
}
.load-row {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.load-row input { flex: 1; }
/* Footer */
.start-footer {
margin-top: 3rem;
font-size: 0.8rem;
color: var(--text-secondary);
opacity: 0.6;
}
/* ====================================
GAME PAGE Main Narrative
==================================== */
.game-page {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Top Bar */
.top-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.8rem 1.5rem;
border-bottom: 1px solid var(--border-gold);
background: rgba(26,10,10,0.9);
backdrop-filter: blur(6px);
position: sticky;
top: 0;
z-index: 10;
}
.top-bar-title {
font-family: 'Noto Serif SC', 'Source Han Serif CN', serif;
font-size: 1rem;
color: var(--gold);
}
.top-bar-actions {
display: flex;
gap: 0.5rem;
}
/* Narrative Area */
.narrative-area {
flex: 1;
padding: 2rem 1.5rem;
max-width: 720px;
margin: 0 auto;
width: 100%;
}
.chapter-title {
font-family: 'Noto Serif SC', 'Source Han Serif CN', serif;
font-size: 1.4rem;
color: var(--gold);
text-align: center;
margin-bottom: 1.5rem;
letter-spacing: 0.3rem;
}
.narrative-text {
font-family: 'Noto Serif SC', 'Source Han Serif CN', serif;
font-size: 1rem;
line-height: 1.9;
color: var(--text-primary);
text-align: justify;
margin-bottom: 2rem;
min-height: 120px;
}
/* Typewriter Animation */
.typewriter-cursor {
display: inline-block;
width: 2px;
height: 1.1em;
background: var(--gold);
margin-left: 2px;
vertical-align: text-bottom;
animation: blink 0.8s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* Choices Area */
.choices-area {
padding: 0 1.5rem 1.5rem;
max-width: 720px;
margin: 0 auto;
width: 100%;
}
.choices-label {
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 0.8rem;
padding-left: 0.3rem;
}
.choice-btn {
display: block;
width: 100%;
padding: 0.9rem 1.2rem;
margin-bottom: 0.6rem;
background: rgba(139,0,0,0.15);
border: 1px solid var(--border-gold-md);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.95rem;
font-family: 'Noto Serif SC', 'Source Han Serif CN', serif;
text-align: left;
cursor: pointer;
transition: all 0.3s;
}
.choice-btn:hover {
background: rgba(139,0,0,0.35);
border-color: var(--gold);
box-shadow: 0 0 12px var(--glow-gold);
transform: translateX(4px);
}
.choice-btn:active {
transform: translateX(2px);
}
.choice-btn .choice-num {
color: var(--gold);
margin-right: 0.5rem;
font-weight: 600;
}
/* Free Input */
.free-input-row {
display: flex;
gap: 0.5rem;
margin-top: 0.3rem;
}
.free-input-row input {
flex: 1;
font-family: 'Noto Serif SC', 'Source Han Serif CN', serif;
}
/* Status Bar */
.status-bar {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-gold);
background: rgba(13,13,26,0.6);
backdrop-filter: blur(6px);
}
.status-toggle {
font-size: 0.8rem;
color: var(--text-secondary);
cursor: pointer;
margin-bottom: 0.5rem;
user-select: none;
}
.status-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem 1.5rem;
}
.stat-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.stat-icon { width: 1.2rem; text-align: center; }
.stat-label {
width: 3rem;
color: var(--text-secondary);
flex-shrink: 0;
}
.stat-bar-bg {
flex: 1;
height: 6px;
background: rgba(201,169,110,0.1);
border-radius: 3px;
overflow: hidden;
}
.stat-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.6s ease;
}
.stat-bar-fill.power { background: linear-gradient(90deg, #8b0000, #c9a96e); }
.stat-bar-fill.status { background: linear-gradient(90deg, #6b21a8, #c9a96e); }
.stat-bar-fill.emotion { background: linear-gradient(90deg, #b8860b, #e8d5a3); }
.stat-bar-fill.conflict{ background: linear-gradient(90deg, #4a1a2e, #8b0000); }
.stat-value {
width: 1.8rem;
text-align: right;
color: var(--gold-light);
font-size: 0.8rem;
}
/* Bottom Bar */
.bottom-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1.5rem;
font-size: 0.75rem;
color: var(--text-secondary);
border-top: 1px solid var(--border-gold);
background: rgba(26,10,10,0.9);
}
/* Status collapsed */
.status-bar.collapsed .status-grid { display: none; }
/* ====================================
SAVE PAGE
==================================== */
.save-page {
min-height: 100vh;
padding: 2rem 1.5rem;
}
.save-page h1 {
font-family: 'Noto Serif SC', 'Source Han Serif CN', serif;
text-align: center;
margin-bottom: 2rem;
letter-spacing: 0.4rem;
}
.save-list {
max-width: 600px;
margin: 0 auto;
}
.save-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.2rem;
margin-bottom: 0.8rem;
background: rgba(26,10,10,0.7);
border: 1px solid var(--border-gold);
border-radius: 6px;
transition: border-color 0.3s;
}
.save-item:hover {
border-color: var(--gold);
}
.save-info { flex: 1; }
.save-id {
font-family: 'Noto Serif SC', 'Source Han Serif CN', serif;
font-size: 1rem;
color: var(--gold);
}
.save-detail {
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 0.2rem;
}
.save-actions {
display: flex;
gap: 0.4rem;
}
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-secondary);
}
/* ====================================
LOADING & TRANSITIONS
==================================== */
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem;
color: var(--text-secondary);
}
.loading-dot {
width: 6px;
height: 6px;
background: var(--gold);
border-radius: 50%;
animation: pulse 1.2s infinite;
}
.loading-dot:nth-child(2) { animation-delay: 0.2s; }
.loading-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes pulse {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1.2); }
}
/* Fade transition */
.fade-in {
animation: fadeIn 0.6s ease forwards;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* Toast notification */
.toast {
position: fixed;
top: 1rem;
right: 1rem;
padding: 0.8rem 1.5rem;
background: rgba(26,10,10,0.95);
border: 1px solid var(--gold);
border-radius: 6px;
color: var(--gold-light);
font-size: 0.9rem;
z-index: 100;
animation: slideIn 0.3s ease, fadeOut 0.3s ease 2.5s forwards;
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes fadeOut {
to { opacity: 0; transform: translateY(-10px); }
}
/* ====================================
Responsive
==================================== */
@media (max-width: 600px) {
.start-card { padding: 1.5rem; }
.start-title { font-size: 1.8rem; letter-spacing: 0.5rem; }
.status-grid { grid-template-columns: 1fr; }
.top-bar-title { font-size: 0.85rem; }
}