feat(script-runtime): 服务器端蓝图执行模块 (#322)
* feat(script-runtime): 添加服务器端蓝图执行模块 - ServerBlueprintVM: 服务器端蓝图虚拟机 - CPULimiter: CPU 时间和步数限制 - IntentCollector: 意图收集系统 - FileMemoryStore: 文件系统持久化 - ServerExecutionContext: 服务器执行上下文 * refactor(script-runtime): 分离引擎接口与游戏逻辑 - 重构 IntentTypes.ts 只保留基础 IIntent 接口和通用常量 - IntentCollector 改为泛型类,支持任意意图类型 - ServerExecutionContext 改为泛型类,支持任意游戏状态类型 - ServerBlueprintVM 改为泛型类,使用 TGameState 和 TIntent 类型参数 - 移除游戏特定类型(IUnitState, ISpawnerState 等),由游戏项目定义 - 添加 IntentKeyExtractor 机制用于防止重复意图 * feat(script-runtime): 添加服务器端游戏循环框架 - PlayerSession: 封装单个玩家的 VM、蓝图和 Memory 状态 - TickScheduler: 管理所有玩家会话,调度每 tick 的蓝图执行 - IIntentProcessor: 意图处理器接口,由游戏项目实现 - IntentProcessorBase: 意图处理器基类,提供常用处理模式 - IntentProcessorRegistry: 按类型注册意图处理器 - GameLoop: 完整的游戏主循环,协调各组件工作 * feat(script-runtime): 添加通用蓝图节点 Memory 节点: - GetMemory: 读取玩家 Memory - SetMemory: 写入玩家 Memory - HasMemoryKey: 检查键是否存在 - DeleteMemory: 删除 Memory 键 Log 节点: - Log: 记录日志 - Warn: 记录警告 - Error: 记录错误 Game 信息节点: - GetTick: 获取当前 tick - GetPlayerId: 获取玩家 ID - GetDeltaTime: 获取增量时间 - GetGameState: 获取游戏状态 提供 registerScriptRuntimeNodes() 用于批量注册节点 * fix(script-runtime): 修复 CI 构建错误 - 更新 tsconfig.json 继承 tsconfig.base.json - 添加 references 到 core 和 blueprint 包 - 更新 pnpm-lock.yaml * fix(script-runtime): 修复 DTS 构建错误 - 添加 tsconfig.build.json 用于 tsup 构建 - 更新 tsup.config.ts 使用 tsconfig.build.json - 分离构建配置和类型检查配置
This commit is contained in:
339
packages/script-runtime/src/persistence/FileMemoryStore.ts
Normal file
339
packages/script-runtime/src/persistence/FileMemoryStore.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* @zh 文件系统 Memory 存储
|
||||
* @en File System Memory Store
|
||||
*
|
||||
* @zh 使用文件系统存储玩家 Memory 和世界状态
|
||||
* @en Uses file system to store player Memory and world state
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
import type {
|
||||
IMemoryStore,
|
||||
PlayerMemory,
|
||||
WorldState,
|
||||
MemoryStoreStats
|
||||
} from './IMemoryStore';
|
||||
|
||||
/**
|
||||
* @zh 文件存储配置
|
||||
* @en File storage configuration
|
||||
*/
|
||||
export interface FileMemoryStoreConfig {
|
||||
/**
|
||||
* @zh 存储根目录
|
||||
* @en Storage root directory
|
||||
*/
|
||||
basePath: string;
|
||||
|
||||
/**
|
||||
* @zh 是否美化 JSON 输出
|
||||
* @en Whether to prettify JSON output
|
||||
*/
|
||||
prettyPrint?: boolean;
|
||||
|
||||
/**
|
||||
* @zh Memory 大小限制(字节)
|
||||
* @en Memory size limit (bytes)
|
||||
*/
|
||||
memorySizeLimit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 默认配置
|
||||
* @en Default configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG: Required<FileMemoryStoreConfig> = {
|
||||
basePath: './data',
|
||||
prettyPrint: true,
|
||||
memorySizeLimit: 2 * 1024 * 1024 // 2MB
|
||||
};
|
||||
|
||||
/**
|
||||
* @zh 文件系统 Memory 存储
|
||||
* @en File System Memory Store
|
||||
*
|
||||
* @zh 简单的文件存储实现,适合开发和小规模部署
|
||||
* @en Simple file storage implementation, suitable for development and small deployments
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const store = new FileMemoryStore({ basePath: './game-data' });
|
||||
* await store.init();
|
||||
*
|
||||
* // 保存玩家 Memory | Save player Memory
|
||||
* await store.savePlayerMemory('player1', { creeps: {} });
|
||||
*
|
||||
* // 加载玩家 Memory | Load player Memory
|
||||
* const memory = await store.loadPlayerMemory('player1');
|
||||
* ```
|
||||
*/
|
||||
export class FileMemoryStore implements IMemoryStore {
|
||||
/**
|
||||
* @zh 配置
|
||||
* @en Configuration
|
||||
*/
|
||||
private readonly _config: Required<FileMemoryStoreConfig>;
|
||||
|
||||
/**
|
||||
* @zh Memory 目录路径
|
||||
* @en Memory directory path
|
||||
*/
|
||||
private readonly _memoryPath: string;
|
||||
|
||||
/**
|
||||
* @zh 世界状态文件路径
|
||||
* @en World state file path
|
||||
*/
|
||||
private readonly _worldPath: string;
|
||||
|
||||
/**
|
||||
* @zh 是否已初始化
|
||||
* @en Whether initialized
|
||||
*/
|
||||
private _initialized: boolean = false;
|
||||
|
||||
constructor(config: Partial<FileMemoryStoreConfig> = {}) {
|
||||
this._config = { ...DEFAULT_CONFIG, ...config };
|
||||
this._memoryPath = path.join(this._config.basePath, 'memory');
|
||||
this._worldPath = path.join(this._config.basePath, 'world.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 初始化存储(创建目录)
|
||||
* @en Initialize storage (create directories)
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
if (this._initialized) return;
|
||||
|
||||
try {
|
||||
await fs.mkdir(this._memoryPath, { recursive: true });
|
||||
this._initialized = true;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to initialize storage: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 确保已初始化
|
||||
* @en Ensure initialized
|
||||
*/
|
||||
private async _ensureInit(): Promise<void> {
|
||||
if (!this._initialized) {
|
||||
await this.init();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取玩家 Memory 文件路径
|
||||
* @en Get player Memory file path
|
||||
*/
|
||||
private _getPlayerMemoryPath(playerId: string): string {
|
||||
// 清理 playerId 以防止路径遍历 | Sanitize playerId to prevent path traversal
|
||||
const safeId = playerId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
return path.join(this._memoryPath, `${safeId}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 加载玩家 Memory
|
||||
* @en Load player Memory
|
||||
*/
|
||||
async loadPlayerMemory(playerId: string): Promise<PlayerMemory> {
|
||||
await this._ensureInit();
|
||||
|
||||
const filePath = this._getPlayerMemoryPath(playerId);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
return JSON.parse(content) as PlayerMemory;
|
||||
} catch (error) {
|
||||
// 文件不存在则返回空对象 | Return empty object if file doesn't exist
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`Failed to load Memory for ${playerId}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 保存玩家 Memory
|
||||
* @en Save player Memory
|
||||
*/
|
||||
async savePlayerMemory(playerId: string, memory: PlayerMemory): Promise<void> {
|
||||
await this._ensureInit();
|
||||
|
||||
const content = this._config.prettyPrint
|
||||
? JSON.stringify(memory, null, 2)
|
||||
: JSON.stringify(memory);
|
||||
|
||||
// 检查大小限制 | Check size limit
|
||||
if (content.length > this._config.memorySizeLimit) {
|
||||
throw new Error(
|
||||
`Memory size (${content.length} bytes) exceeds limit ` +
|
||||
`(${this._config.memorySizeLimit} bytes) for player ${playerId}`
|
||||
);
|
||||
}
|
||||
|
||||
const filePath = this._getPlayerMemoryPath(playerId);
|
||||
|
||||
try {
|
||||
await fs.writeFile(filePath, content, 'utf-8');
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to save Memory for ${playerId}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 批量保存玩家 Memory
|
||||
* @en Batch save player Memory
|
||||
*/
|
||||
async savePlayerMemoryBatch(
|
||||
entries: Array<{ playerId: string; memory: PlayerMemory }>
|
||||
): Promise<void> {
|
||||
await this._ensureInit();
|
||||
|
||||
// 并行保存所有玩家 | Save all players in parallel
|
||||
await Promise.all(
|
||||
entries.map(({ playerId, memory }) =>
|
||||
this.savePlayerMemory(playerId, memory)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 删除玩家 Memory
|
||||
* @en Delete player Memory
|
||||
*/
|
||||
async deletePlayerMemory(playerId: string): Promise<void> {
|
||||
await this._ensureInit();
|
||||
|
||||
const filePath = this._getPlayerMemoryPath(playerId);
|
||||
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch (error) {
|
||||
// 文件不存在则忽略 | Ignore if file doesn't exist
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw new Error(`Failed to delete Memory for ${playerId}: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取所有玩家 ID
|
||||
* @en Get all player IDs
|
||||
*/
|
||||
async getAllPlayerIds(): Promise<string[]> {
|
||||
await this._ensureInit();
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(this._memoryPath);
|
||||
return files
|
||||
.filter(f => f.endsWith('.json'))
|
||||
.map(f => f.slice(0, -5)); // 移除 .json 后缀 | Remove .json suffix
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 加载世界状态
|
||||
* @en Load world state
|
||||
*/
|
||||
async loadWorldState(): Promise<WorldState | null> {
|
||||
await this._ensureInit();
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(this._worldPath, 'utf-8');
|
||||
return JSON.parse(content) as WorldState;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw new Error(`Failed to load world state: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 保存世界状态
|
||||
* @en Save world state
|
||||
*/
|
||||
async saveWorldState(state: WorldState): Promise<void> {
|
||||
await this._ensureInit();
|
||||
|
||||
const stateWithTimestamp: WorldState = {
|
||||
...state,
|
||||
lastSaveTime: Date.now()
|
||||
};
|
||||
|
||||
const content = this._config.prettyPrint
|
||||
? JSON.stringify(stateWithTimestamp, null, 2)
|
||||
: JSON.stringify(stateWithTimestamp);
|
||||
|
||||
try {
|
||||
await fs.writeFile(this._worldPath, content, 'utf-8');
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to save world state: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取存储统计信息
|
||||
* @en Get storage statistics
|
||||
*/
|
||||
async getStats(): Promise<MemoryStoreStats> {
|
||||
await this._ensureInit();
|
||||
|
||||
const playerIds = await this.getAllPlayerIds();
|
||||
let totalSize = 0;
|
||||
let lastSaveTime = 0;
|
||||
|
||||
for (const playerId of playerIds) {
|
||||
try {
|
||||
const filePath = this._getPlayerMemoryPath(playerId);
|
||||
const stats = await fs.stat(filePath);
|
||||
totalSize += stats.size;
|
||||
if (stats.mtimeMs > lastSaveTime) {
|
||||
lastSaveTime = stats.mtimeMs;
|
||||
}
|
||||
} catch {
|
||||
// 忽略错误 | Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// 加上世界状态文件大小 | Add world state file size
|
||||
try {
|
||||
const worldStats = await fs.stat(this._worldPath);
|
||||
totalSize += worldStats.size;
|
||||
if (worldStats.mtimeMs > lastSaveTime) {
|
||||
lastSaveTime = worldStats.mtimeMs;
|
||||
}
|
||||
} catch {
|
||||
// 忽略错误 | Ignore errors
|
||||
}
|
||||
|
||||
return {
|
||||
playerCount: playerIds.length,
|
||||
totalSize,
|
||||
lastSaveTime
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清除所有数据(慎用!)
|
||||
* @en Clear all data (use with caution!)
|
||||
*/
|
||||
async clearAll(): Promise<void> {
|
||||
await this._ensureInit();
|
||||
|
||||
const playerIds = await this.getAllPlayerIds();
|
||||
await Promise.all(playerIds.map(id => this.deletePlayerMemory(id)));
|
||||
|
||||
try {
|
||||
await fs.unlink(this._worldPath);
|
||||
} catch {
|
||||
// 忽略错误 | Ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user