199 lines
5.3 KiB
JavaScript
199 lines
5.3 KiB
JavaScript
/**
|
||
* 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
|
||
};
|