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:
YHH
2025-12-25 11:00:43 +08:00
committed by GitHub
parent a75c61c049
commit 6b8b65ae16
25 changed files with 4961 additions and 0 deletions

View 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
}
}
}