199 lines
5.3 KiB
JavaScript
Raw Permalink Normal View History

/**
* 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
};